del: 移除安卓支持

This commit is contained in:
mofeng-git
2026-07-14 21:01:47 +08:00
parent 139064de35
commit 1ded7a8a66
88 changed files with 299 additions and 7107 deletions

View File

@@ -71,68 +71,6 @@ desktop = [
"dep:cpal", "dep:cpal",
"dep:windows-sys", "dep:windows-sys",
] ]
android = [
"dep:anyhow",
"dep:argon2",
"dep:arc-swap",
"dep:async-stream",
"dep:async-trait",
"dep:axum",
"dep:axum-extra",
"dep:base64",
"dep:bytemuck",
"dep:bytes",
"dep:futures",
"dep:gpio-cdev",
"dep:hwcodec",
"dep:libc",
"dep:libyuv",
"dep:mime_guess",
"dep:nix",
"dep:parking_lot",
"dep:protobuf",
"dep:rand",
"dep:rcgen",
"dep:reqwest",
"dep:rtp",
"dep:rtsp-types",
"dep:rust-embed",
"dep:rustls",
"dep:sdp-types",
"dep:serde",
"dep:serde_json",
"dep:toml_edit",
"dep:serialport",
"dep:sha2",
"dep:sodiumoxide",
"dep:des",
"dep:sqlx",
"dep:alsa",
"dep:audiopus",
"dep:thiserror",
"dep:time",
"dep:tempfile",
"dep:tokio",
"dep:tokio-tungstenite",
"dep:tokio-util",
"dep:axum-server",
"dep:tower-http",
"dep:tracing",
"dep:tracing-log",
"dep:tracing-subscriber",
"dep:turbojpeg",
"dep:typeshare",
"dep:urlencoding",
"dep:uuid",
"dep:ventoy-img",
"dep:v4l2r",
"dep:webrtc",
"dep:xxhash-rust",
]
android-mediacodec = [
"android",
]
[dependencies] [dependencies]
# Async runtime # Async runtime
tokio = { version = "1", features = ["full"], optional = true } tokio = { version = "1", features = ["full"], optional = true }
@@ -229,7 +167,7 @@ sha2 = { version = "0.10", optional = true }
# TypeScript type generation # TypeScript type generation
typeshare = { version = "1.0", optional = true } typeshare = { version = "1.0", optional = true }
[target.'cfg(any(unix, windows))'.dependencies] [target.'cfg(any(target_os = "linux", windows))'.dependencies]
# Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux) # Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux)
hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true } hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true }
libyuv = { path = "res/vcpkg/libyuv", optional = true } libyuv = { path = "res/vcpkg/libyuv", optional = true }
@@ -237,15 +175,10 @@ turbojpeg = { version = "1.3", optional = true }
# Note: audiopus links to libopus.so (unavoidable for audio support) # Note: audiopus links to libopus.so (unavoidable for audio support)
audiopus = { version = "0.2", optional = true } audiopus = { version = "0.2", optional = true }
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# Utilities # Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true } nix = { version = "0.30", default-features = false, features = ["fs", "socket", "net", "hostname", "poll"], optional = true }
[target.'cfg(target_os = "android")'.dependencies]
# Utilities
nix = { version = "0.30", default-features = false, features = ["fs", "socket", "hostname", "poll"], optional = true }
[target.'cfg(unix)'.dependencies]
# Video capture (V4L2) # Video capture (V4L2)
v4l2r = { path = "libs/v4l2r", optional = true } v4l2r = { path = "libs/v4l2r", optional = true }

7
android/.gitignore vendored
View File

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

View File

@@ -1,559 +0,0 @@
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 oneKvmVersion = Regex("""(?m)^version\s*=\s*"([^"]+)"""")
.find(rootCrateDir.file("Cargo.toml").asFile.readText())
?.groupValues
?.get(1)
?: throw GradleException("Failed to resolve version from root Cargo.toml")
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 = oneKvmVersion
}
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 scriptFile = androidFfmpegBuildScript.asFile
val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp")
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
commandLine(
"bash",
scriptFile.absolutePath,
"--output",
ffmpegRoot.absolutePath,
"--ndk",
androidNdkDir.absolutePath,
"--api",
androidApiLevel.toString(),
"--abis",
selectedAndroidAbis.joinToString(","),
)
inputs.file(scriptFile)
outputs.dir(ffmpegRoot)
onlyIf {
val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() }
val hasCurrentBuildStamp =
stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile)
!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

@@ -1,36 +0,0 @@
<?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

@@ -1,13 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,30 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,452 +0,0 @@
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

@@ -1,21 +0,0 @@
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

@@ -1,413 +0,0 @@
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

@@ -1,75 +0,0 @@
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

@@ -1,38 +0,0 @@
<?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

@@ -1,13 +0,0 @@
<?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

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

View File

@@ -1,7 +0,0 @@
<?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>

View File

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

View File

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

Binary file not shown.

View File

@@ -1,7 +0,0 @@
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
View File

@@ -1,251 +0,0 @@
#!/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
View File

@@ -1,94 +0,0 @@
@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

View File

@@ -1,21 +0,0 @@
[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

@@ -1,24 +0,0 @@
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);
}
}

View File

@@ -1,182 +0,0 @@
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

@@ -1,39 +0,0 @@
pluginManagement {
fun isEnabled(value: String?): Boolean = when (value?.lowercase()) {
"1", "true", "yes", "on" -> true
else -> false
}
val mirrorAcceleration = isEnabled(System.getenv("CHINAMIRRO"))
repositories {
if (mirrorAcceleration) {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/gradle-plugin")
}
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
fun isEnabled(value: String?): Boolean = when (value?.lowercase()) {
"1", "true", "yes", "on" -> true
else -> false
}
val mirrorAcceleration = isEnabled(System.getenv("CHINAMIRRO"))
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
if (mirrorAcceleration) {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
}
google()
mavenCentral()
}
}
rootProject.name = "OneKvmAndroidHost"
include(":app")

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env bash
# Build Android APKs using the Docker build image.
# Usage: ./build/build-android.sh [arm64|armv7|all|help]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DOCKERFILE="$PROJECT_ROOT/build/cross/Dockerfile.android"
IMAGE_NAME="${ONE_KVM_ANDROID_DOCKER_IMAGE:-one-kvm-android-build:cn}"
fail() {
echo "Error: $*" >&2
exit 1
}
build_android() {
local arch="$1"
local docker_build_args=()
local gradle_distribution_url="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"
local gradle_distribution_url_cn="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"
local gradle_network_timeout="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"
if [[ "${CHINAMIRRO:-}" == "1" ]]; then
docker_build_args+=("--build-arg" "CHINAMIRRO=1")
docker_build_args+=("--build-arg" "DEBIAN_IMAGE=${DEBIAN_IMAGE:-docker.1ms.run/library/debian:11}")
docker_build_args+=("--build-arg" "RUSTUP_DIST_SERVER_CN=${RUSTUP_DIST_SERVER_CN:-https://rsproxy.cn}")
docker_build_args+=("--build-arg" "RUSTUP_UPDATE_ROOT_CN=${RUSTUP_UPDATE_ROOT_CN:-https://rsproxy.cn/rustup}")
docker_build_args+=("--build-arg" "CARGO_INDEX_CN=${CARGO_INDEX_CN:-https://rsproxy.cn/crates.io-index}")
docker_build_args+=("--build-arg" "CARGO_REGISTRY_CN=${CARGO_REGISTRY_CN:-sparse+https://rsproxy.cn/index/}")
docker_build_args+=("--build-arg" "MAVEN_REPOSITORY_CN=${MAVEN_REPOSITORY_CN:-https://maven.aliyun.com/repository/public}")
docker_build_args+=("--build-arg" "GOOGLE_MAVEN_REPOSITORY_CN=${GOOGLE_MAVEN_REPOSITORY_CN:-https://maven.aliyun.com/repository/google}")
docker_build_args+=("--build-arg" "GRADLE_PLUGIN_REPOSITORY_CN=${GRADLE_PLUGIN_REPOSITORY_CN:-https://maven.aliyun.com/repository/gradle-plugin}")
docker_build_args+=("--build-arg" "GRADLE_DISTRIBUTION_URL_CN=$gradle_distribution_url_cn")
if [[ -z "$gradle_distribution_url" ]]; then
gradle_distribution_url="$gradle_distribution_url_cn"
fi
fi
if [[ "${ONE_KVM_ANDROID_SKIP_DOCKER_BUILD:-0}" == "1" ]]; then
echo "=== Skipping Android image build: $IMAGE_NAME ==="
else
echo "=== Building Android image: $IMAGE_NAME ==="
docker build \
-f "$DOCKERFILE" \
-t "$IMAGE_NAME" \
"${docker_build_args[@]}" \
"$PROJECT_ROOT/build/cross"
fi
echo "=== Building Android APK: $arch ==="
docker run --rm \
-v "$PROJECT_ROOT:/workspace" \
-w /workspace \
-e "CHINAMIRRO=${CHINAMIRRO:-0}" \
-e "GH_PROXY=${GH_PROXY:-https://gh-proxy.com}" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL=$gradle_distribution_url" \
-e "ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=$gradle_distribution_url_cn" \
-e "ONE_KVM_GRADLE_NETWORK_TIMEOUT=$gradle_network_timeout" \
"$IMAGE_NAME" \
"$arch"
}
[[ -f "$DOCKERFILE" ]] || fail "Android Dockerfile not found: $DOCKERFILE"
command -v docker >/dev/null 2>&1 || fail "docker is required"
case "${1:-all}" in
all)
build_android all
;;
arm64)
build_android arm64
;;
armv7)
build_android armv7
;;
help | --help | -h)
cat <<'EOF'
Usage: build/build-android.sh [arch|help]
Commands:
all (default) Build arm64 and armv7 APKs
arm64 Build only arm64 APK
armv7 Build only ARMv7 APK
help Show this help
Examples:
build/build-android.sh
build/build-android.sh arm64
CHINAMIRRO=1 build/build-android.sh all
CHINAMIRRO=1 ONE_KVM_GRADLE_DISTRIBUTION_URL=https://mirrors.aliyun.com/macports/distfiles/gradle/gradle-9.1.0-bin.zip build/build-android.sh all
Environment:
ONE_KVM_ANDROID_SKIP_DOCKER_BUILD=1 Reuse an already loaded Docker image
APK output:
target/android/one-kvm_<version>_<arm32|arm64>.apk
EOF
;;
*)
fail "Unknown argument: $1"
;;
esac

View File

@@ -1,319 +0,0 @@
# Android build image for One-KVM
# Based on Debian 11 for stable toolchain/runtime compatibility
ARG DEBIAN_IMAGE=debian:11
FROM ${DEBIAN_IMAGE}
ARG CHINAMIRRO=0
ARG ANDROID_SDK_ROOT=/root/android-sdk
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708_latest
ARG ANDROID_NDK_VERSION=27.3.13750724
ARG ANDROID_PLATFORM=36
ARG ANDROID_BUILD_TOOLS=36.0.0
ARG CARGO_NDK_VERSION=4.1.2
ARG RUSTUP_DIST_SERVER_CN=https://rsproxy.cn
ARG RUSTUP_UPDATE_ROOT_CN=https://rsproxy.cn/rustup
ARG CARGO_INDEX_CN=https://rsproxy.cn/crates.io-index
ARG CARGO_REGISTRY_CN=sparse+https://rsproxy.cn/index/
ARG MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/public
ARG GOOGLE_MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/google
ARG GRADLE_PLUGIN_REPOSITORY_CN=https://maven.aliyun.com/repository/gradle-plugin
ARG GRADLE_DISTRIBUTION_URL_CN=https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip
ARG ANDROID_CMDLINE_TOOLS_URL=
ENV DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
ENV ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT}
ENV ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_NDK_ROOT=${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}
ENV ANDROID_BUILD_TOOLS=${ANDROID_BUILD_TOOLS}
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH=/root/.cargo/bin:${PATH}
ENV ONE_KVM_GRADLE_DISTRIBUTION_URL_CN=${GRADLE_DISTRIBUTION_URL_CN}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
sed -i -E \
-e 's|http://deb.debian.org/debian([[:space:]])|http://mirrors.tuna.tsinghua.edu.cn/debian\1|g' \
/etc/apt/sources.list; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
bzip2 \
unzip \
zip \
git \
bash \
build-essential \
pkg-config \
cmake \
ninja-build \
autoconf \
automake \
libtool \
nasm \
yasm \
python3 \
openjdk-17-jdk-headless \
libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
RUN if [ "$CHINAMIRRO" = "1" ]; then \
export RUSTUP_DIST_SERVER=${RUSTUP_DIST_SERVER_CN}; \
export RUSTUP_UPDATE_ROOT=${RUSTUP_UPDATE_ROOT_CN}; \
mkdir -p /root/.cargo; \
printf '%s\n' \
'[source.crates-io]' \
"replace-with = 'rsproxy-sparse'" \
'[source.rsproxy]' \
"registry = '${CARGO_INDEX_CN}'" \
'[source.rsproxy-sparse]' \
"registry = '${CARGO_REGISTRY_CN}'" \
'[registries.rsproxy]' \
"index = '${CARGO_INDEX_CN}'" \
'[net]' \
'git-fetch-with-cli = true' \
> /root/.cargo/config.toml; \
fi \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable \
&& cargo install cargo-ndk --version ${CARGO_NDK_VERSION} --locked \
&& rustup target add armv7-linux-androideabi aarch64-linux-android
RUN mkdir -p /opt/android-cmdline-tools \
&& cd /tmp \
&& if [ -n "$ANDROID_CMDLINE_TOOLS_URL" ]; then \
wget -q "$ANDROID_CMDLINE_TOOLS_URL" -O cmdline-tools.zip; \
else \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}.zip -O cmdline-tools.zip; \
fi \
&& unzip -q cmdline-tools.zip -d /opt/android-cmdline-tools \
&& mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& mv /opt/android-cmdline-tools/cmdline-tools/* ${ANDROID_SDK_ROOT}/cmdline-tools/latest/ \
&& rm -rf /tmp/cmdline-tools.zip /opt/android-cmdline-tools
RUN mkdir -p ${ANDROID_SDK_ROOT}/licenses \
&& yes | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} --licenses >/dev/null \
&& ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --sdk_root=${ANDROID_SDK_ROOT} \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM}" \
"build-tools;${ANDROID_BUILD_TOOLS}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;3.22.1" \
&& mkdir -p ${ANDROID_NDK_HOME}
RUN if [ "$CHINAMIRRO" = "1" ]; then \
mkdir -p /root/.gradle; \
printf '%s\n' \
"beforeSettings { settings ->" \
" settings.pluginManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.pluginManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
" settings.pluginManagement.repositories.maven { url = uri('${GRADLE_PLUGIN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" settings.dependencyResolutionManagement.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
"allprojects {" \
" buildscript.repositories.maven { url = uri('${GOOGLE_MAVEN_REPOSITORY_CN}') }" \
" buildscript.repositories.maven { url = uri('${MAVEN_REPOSITORY_CN}') }" \
"}" \
> /root/.gradle/init.gradle; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
libclang-dev \
llvm \
&& rm -rf /var/lib/apt/lists/*
ENV LIBCLANG_PATH=/usr/lib/llvm-11/lib
RUN printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'' \
'PROJECT_ROOT="${ONE_KVM_ANDROID_PROJECT_ROOT:-/workspace}"' \
'ANDROID_DIR="${PROJECT_ROOT}/android"' \
'BUILD_TYPE="release"' \
'ARCH="${1:-all}"' \
'FFMPEG_ROOT="${ONE_KVM_ANDROID_FFMPEG_ROOT:-${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec}"' \
'OUTPUT_DIR="${PROJECT_ROOT}/target/android"' \
'SIGNING_DIR="${PROJECT_ROOT}/target/android-signing"' \
'KEYSTORE_PATH="${SIGNING_DIR}/one-kvm-release.jks"' \
'KEY_ALIAS="one-kvm-release"' \
'KEY_PASSWORD="one-kvm-release"' \
'ANDROID_BUILD_TOOLS_DIR="${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS}"' \
'WRAPPER_PROPERTIES="$ANDROID_DIR/gradle/wrapper/gradle-wrapper.properties"' \
'GRADLE_DISTRIBUTION_URL="${ONE_KVM_GRADLE_DISTRIBUTION_URL:-}"' \
'GRADLE_DISTRIBUTION_URL_CN="${ONE_KVM_GRADLE_DISTRIBUTION_URL_CN:-https://mirrors.cloud.tencent.com/gradle/gradle-9.1.0-bin.zip}"' \
'GRADLE_NETWORK_TIMEOUT="${ONE_KVM_GRADLE_NETWORK_TIMEOUT:-120000}"' \
'' \
'usage() {' \
' cat <<EOF' \
'Usage:' \
' docker run --rm -v "$PWD:/workspace" one-kvm-android-build:cn [arm64|armv7|all|help]' \
'' \
'Commands:' \
' all Build arm64 and armv7 APKs. Default.' \
' arm64 Build only arm64 APK.' \
' armv7 Build only ARMv7 APK.' \
' help Show this help.' \
'' \
'APK output:' \
' target/android/one-kvm_<version>_<arm32|arm64>.apk' \
'EOF' \
'}' \
'' \
'fail() {' \
' echo "Error: $*" >&2' \
' exit 1' \
'}' \
'' \
'read_project_version() {' \
' local version' \
' version="$(awk -F "\"" '"'"'/^version[[:space:]]*=/ { print $2; exit }'"'"' "$PROJECT_ROOT/Cargo.toml")"' \
' [[ -n "$version" ]] || fail "Failed to resolve version from $PROJECT_ROOT/Cargo.toml"' \
' printf "%s\n" "$version"' \
'}' \
'' \
'copy_apks() {' \
' local flavor="$1"' \
' local src_dir="$ANDROID_DIR/app/build/outputs/apk/$flavor/$BUILD_TYPE"' \
' local found=0' \
' mkdir -p "$OUTPUT_DIR"' \
' for apk in "$src_dir"/*.apk; do' \
' [[ -f "$apk" ]] || continue' \
' sign_apk "$apk" "$OUTPUT_DIR/one-kvm_${PROJECT_VERSION}_${flavor}.apk"' \
' found=1' \
' done' \
' [[ "$found" == "1" ]] || fail "No APK files found in: $src_dir"' \
'}' \
'' \
'ensure_keystore() {' \
' if [[ -f "$KEYSTORE_PATH" ]]; then' \
' return' \
' fi' \
' mkdir -p "$SIGNING_DIR"' \
' keytool -genkeypair -noprompt -keystore "$KEYSTORE_PATH" -storetype PKCS12 -alias "$KEY_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 -storepass "$KEY_PASSWORD" -keypass "$KEY_PASSWORD" -dname "CN=One-KVM, OU=One-KVM, O=One-KVM, L=Local, S=Local, C=US" >/dev/null' \
'}' \
'' \
'sign_apk() {' \
' local input_apk="$1"' \
' local output_apk="$2"' \
' local aligned_apk' \
' aligned_apk="$(mktemp --suffix=.apk)"' \
' "$ANDROID_BUILD_TOOLS_DIR/zipalign" -f -p 4 "$input_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" sign --ks "$KEYSTORE_PATH" --ks-key-alias "$KEY_ALIAS" --ks-pass "pass:$KEY_PASSWORD" --key-pass "pass:$KEY_PASSWORD" --out "$output_apk" "$aligned_apk"' \
' "$ANDROID_BUILD_TOOLS_DIR/apksigner" verify --verbose "$output_apk" >/dev/null' \
' rm -f "$aligned_apk"' \
'}' \
'' \
'cd "$PROJECT_ROOT"' \
'' \
'case "$ARCH" in' \
'help | --help | -h)' \
' usage' \
' exit 0' \
' ;;' \
'esac' \
'' \
'[[ -d "$ANDROID_DIR" ]] || fail "Android project not found: $ANDROID_DIR"' \
'[[ -x "$ANDROID_DIR/gradlew" ]] || fail "Gradle wrapper is not executable: $ANDROID_DIR/gradlew"' \
'[[ -f "$WRAPPER_PROPERTIES" ]] || fail "Gradle wrapper properties not found: $WRAPPER_PROPERTIES"' \
'' \
'ORIGINAL_WRAPPER_PROPERTIES="$(mktemp)"' \
'cp "$WRAPPER_PROPERTIES" "$ORIGINAL_WRAPPER_PROPERTIES"' \
'cleanup_wrapper_properties() {' \
' cp "$ORIGINAL_WRAPPER_PROPERTIES" "$WRAPPER_PROPERTIES"' \
' rm -f "$ORIGINAL_WRAPPER_PROPERTIES"' \
'}' \
'trap cleanup_wrapper_properties EXIT' \
'' \
'if [[ "${CHINAMIRRO:-0}" == "1" && -z "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' GRADLE_DISTRIBUTION_URL="$GRADLE_DISTRIBUTION_URL_CN"' \
'fi' \
'' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' WRAPPER_PROPERTIES_TMP="$(mktemp)"' \
' awk -v url="$GRADLE_DISTRIBUTION_URL" -v timeout="$GRADLE_NETWORK_TIMEOUT" '"'"'' \
' BEGIN { seen_url = 0; seen_timeout = 0 }' \
' /^distributionUrl=/ { print "distributionUrl=" url; seen_url = 1; next }' \
' /^networkTimeout=/ { print "networkTimeout=" timeout; seen_timeout = 1; next }' \
' { print }' \
' END {' \
' if (!seen_url) print "distributionUrl=" url;' \
' if (!seen_timeout) print "networkTimeout=" timeout;' \
' }' \
' '"'"' "$WRAPPER_PROPERTIES" > "$WRAPPER_PROPERTIES_TMP"' \
' cp "$WRAPPER_PROPERTIES_TMP" "$WRAPPER_PROPERTIES"' \
' rm -f "$WRAPPER_PROPERTIES_TMP"' \
' if [[ -d /root/.gradle/wrapper/dists ]]; then' \
' find /root/.gradle/wrapper/dists \( -name "*.lck" -o -name "*.part" \) -print0 | xargs -0 -r rm -f' \
' fi' \
'fi' \
'' \
'ensure_keystore' \
'' \
'case "$ARCH" in' \
'arm64)' \
' ANDROID_ABIS="arm64-v8a"' \
' GRADLE_TASK=":app:assembleArm64Release"' \
' APK_FLAVORS="arm64"' \
' ;;' \
'armv7)' \
' ANDROID_ABIS="armeabi-v7a"' \
' GRADLE_TASK=":app:assembleArm32Release"' \
' APK_FLAVORS="arm32"' \
' ;;' \
'all)' \
' ANDROID_ABIS="arm64-v8a,armeabi-v7a"' \
' GRADLE_TASK=":app:assembleRelease"' \
' APK_FLAVORS="arm64 arm32"' \
' ;;' \
'*) fail "Unsupported architecture: $ARCH (expected arm64, armv7, or all)" ;;' \
'esac' \
'' \
'printf "sdk.dir=%s\n" "$ANDROID_HOME" > "$ANDROID_DIR/local.properties"' \
'mkdir -p "$OUTPUT_DIR"' \
'PROJECT_VERSION="$(read_project_version)"' \
'' \
'export ONE_KVM_ANDROID_PROFILE="$BUILD_TYPE"' \
'export ONE_KVM_ANDROID_ABIS="$ANDROID_ABIS"' \
'export ONE_KVM_ANDROID_FFMPEG_ROOT="$FFMPEG_ROOT"' \
'export ANDROID_HOME' \
'export ANDROID_SDK_ROOT' \
'export ANDROID_NDK_HOME' \
'export ANDROID_NDK_ROOT' \
'' \
'echo "Building Android APK"' \
'echo " task: $GRADLE_TASK"' \
'echo " profile: $ONE_KVM_ANDROID_PROFILE"' \
'echo " version: $PROJECT_VERSION"' \
'echo " abis: $ONE_KVM_ANDROID_ABIS"' \
'echo " output: $OUTPUT_DIR"' \
'echo " sdk: $ANDROID_HOME"' \
'echo " ndk: $ANDROID_NDK_HOME"' \
'echo " build tools: $ANDROID_BUILD_TOOLS_DIR"' \
'echo " ffmpeg root: $ONE_KVM_ANDROID_FFMPEG_ROOT"' \
'if [[ -n "$GRADLE_DISTRIBUTION_URL" ]]; then' \
' echo " gradle distribution: $GRADLE_DISTRIBUTION_URL"' \
'fi' \
'' \
'(' \
' cd "$ANDROID_DIR"' \
' ./gradlew "$GRADLE_TASK"' \
')' \
'' \
'for flavor in $APK_FLAVORS; do' \
' copy_apks "$flavor"' \
'done' \
'' \
'echo' \
'echo "APK output:"' \
'ls -1 "$OUTPUT_DIR"' \
> /usr/local/bin/build-one-kvm-android \
&& chmod +x /usr/local/bin/build-one-kvm-android
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/build-one-kvm-android"]
CMD ["all"]

View File

@@ -8,8 +8,6 @@
/ffmpeg/linux/debug /ffmpeg/linux/debug
!/ffmpeg/mac !/ffmpeg/mac
/ffmpeg/mac/debug /ffmpeg/mac/debug
!/ffmpeg/android
/ffmpeg/android/debug
!/ffmpeg/ios !/ffmpeg/ios
/ffmpeg/ios/debug /ffmpeg/ios/debug
/input /input

View File

@@ -41,18 +41,6 @@ Based on the information above, there are several optimizations and changes made
* remove hevc_vaapi because of possible poor quality * remove hevc_vaapi because of possible poor quality
* amf: not tested, https://github.com/GPUOpen-LibrariesAndSDKs/AMF/issues/378 * amf: not tested, https://github.com/GPUOpen-LibrariesAndSDKs/AMF/issues/378
### MacOS
| FFmpeg ram encode | FFmpeg ram decode |
| ------------------ | ------------------ |
| h265 only | Y |
### Android
| FFmpeg ram encode |
| ------------------ |
| Y |
## System requirements ## System requirements
* intel * intel
@@ -76,4 +64,3 @@ Based on the information above, there are several optimizations and changes made
https://docs.nvidia.com/video-technologies/video-codec-sdk/11.1/read-me/index.html https://docs.nvidia.com/video-technologies/video-codec-sdk/11.1/read-me/index.html
https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new?ncid=em-prod-816193 https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new?ncid=em-prod-816193

View File

@@ -21,15 +21,11 @@ fn build_common(builder: &mut Build) {
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
let common_dir = manifest_dir.join("cpp").join("common"); let common_dir = manifest_dir.join("cpp").join("common");
let mut bindings = bindgen::builder() let bindings = bindgen::builder()
.header(common_dir.join("common.h").to_string_lossy().to_string()) .header(common_dir.join("common.h").to_string_lossy().to_string())
.header(common_dir.join("callback.h").to_string_lossy().to_string()) .header(common_dir.join("callback.h").to_string_lossy().to_string())
.rustified_enum(".*") .rustified_enum(".*")
.parse_callbacks(Box::new(CommonCallbacks)); .parse_callbacks(Box::new(CommonCallbacks));
if target_os == "android" {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -62,9 +58,9 @@ fn build_common(builder: &mut Build) {
} }
// Unsupported platforms // Unsupported platforms
if target_os != "windows" && target_os != "linux" && target_os != "android" { if target_os != "windows" && target_os != "linux" {
panic!( panic!(
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.", "Unsupported OS: {}. Only Windows and Linux are supported.",
target_os target_os
); );
} }
@@ -89,123 +85,12 @@ impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
} }
} }
fn print_android_bindgen_env() {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for hwcodec bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"hwcodec Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
mod ffmpeg { mod ffmpeg {
use super::*; use super::*;
pub fn build_ffmpeg(builder: &mut Build) { pub fn build_ffmpeg(builder: &mut Build) {
ffmpeg_ffi(); ffmpeg_ffi();
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
link_android_ffmpeg(builder);
build_ffmpeg_ram(builder);
return;
}
// Try VCPKG first, fallback to system FFmpeg via pkg-config // Try VCPKG first, fallback to system FFmpeg via pkg-config
if let Some(vcpkg_installed) = vcpkg_installed_root() { if let Some(vcpkg_installed) = vcpkg_installed_root() {
link_vcpkg(builder, vcpkg_installed); link_vcpkg(builder, vcpkg_installed);
@@ -220,67 +105,6 @@ mod ffmpeg {
build_ffmpeg_capture(builder); build_ffmpeg_capture(builder);
} }
fn link_android_ffmpeg(builder: &mut Build) {
let root = std::env::var("ONE_KVM_ANDROID_FFMPEG_ROOT").unwrap_or_else(|_| {
panic!(
"ONE_KVM_ANDROID_FFMPEG_ROOT is required when building hwcodec for Android. \
It must point to an FFmpeg Android build with MediaCodec enabled."
)
});
let root = PathBuf::from(root);
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = match target_arch.as_str() {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => target_arch.as_str(),
};
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
if !include_dir.exists() || !lib_dir.exists() {
panic!(
"Invalid ONE_KVM_ANDROID_FFMPEG_ROOT: include/lib not found for ABI {} under {}",
abi,
root.display()
);
}
println!("cargo:rustc-link-search=native={}", lib_dir.display());
builder.include(&include_dir);
let use_static = std::env::var("ONE_KVM_ANDROID_FFMPEG_STATIC")
.map(|value| value != "0")
.unwrap_or(true);
for lib in ["avcodec", "avutil"] {
if use_static {
println!("cargo:rustc-link-lib=static={}", lib);
} else {
println!("cargo:rustc-link-lib={}", lib);
}
}
println!("cargo:rustc-link-lib=log");
println!("cargo:rustc-link-lib=mediandk");
println!("cargo:rustc-link-lib=android");
println!("cargo:rustc-link-lib=dl");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=z");
println!("cargo:rustc-link-lib=c++_shared");
println!("cargo:info=Using Android FFmpeg from {}", root.display());
}
fn vcpkg_installed_root() -> Option<PathBuf> { fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
@@ -530,11 +354,9 @@ mod ffmpeg {
} }
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2 // ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
v v
} else if target_os == "android" {
Vec::new()
} else { } else {
panic!( panic!(
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.", "Unsupported OS: {}. Only Windows and Linux are supported.",
target_os target_os
); );
}; };
@@ -550,13 +372,7 @@ mod ffmpeg {
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h"); let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
println!("cargo:rerun-if-changed={}", ffi_header_path.display()); println!("cargo:rerun-if-changed={}", ffi_header_path.display());
let ffi_header = ffi_header_path.to_string_lossy().to_string(); let ffi_header = ffi_header_path.to_string_lossy().to_string();
let mut bindings = bindgen::builder() let bindings = bindgen::builder().header(ffi_header).rustified_enum(".*");
.header(ffi_header)
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -571,13 +387,7 @@ mod ffmpeg {
.join("ffmpeg_ram_ffi.h") .join("ffmpeg_ram_ffi.h")
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let mut bindings = bindgen::builder() let bindings = bindgen::builder().header(ffi_header).rustified_enum(".*");
.header(ffi_header)
.rustified_enum(".*");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
print_android_bindgen_env();
bindings = bindings.clang_args(android_clang_args());
}
bindings bindings
.generate() .generate()
.unwrap() .unwrap()
@@ -589,12 +399,13 @@ mod ffmpeg {
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support. // RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist. // Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); || std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp")); builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
if enable_rkmpp {
builder.define("ONE_KVM_FFMPEG_RKMPP", None);
}
} else { } else {
println!( println!(
"cargo:info=Skipping ffmpeg_ram_decode.cpp (RKMPP) for arch {}", "cargo:info=Skipping ffmpeg_ram_decode.cpp (RKMPP) for arch {}",
@@ -647,9 +458,7 @@ mod ffmpeg {
.unwrap(); .unwrap();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm")
let enable_rkmpp = target_os != "android"
&& matches!(target_arch.as_str(), "aarch64" | "arm")
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); || std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
if enable_rkmpp { if enable_rkmpp {
// Include RGA headers for NV16->NV12 conversion (RGA im2d API) // Include RGA headers for NV16->NV12 conversion (RGA im2d API)

View File

@@ -24,7 +24,7 @@ bool is_software_h264(const std::string &name) {
// Exclude all hardware encoders // Exclude all hardware encoders
static const char* hw_suffixes[] = { static const char* hw_suffixes[] = {
"nvenc", "amf", "qsv", "vaapi", "rkmpp", "nvenc", "amf", "qsv", "vaapi", "rkmpp",
"v4l2m2m", "videotoolbox", "mediacodec", "_mf" "v4l2m2m", "videotoolbox", "_mf"
}; };
for (const auto& suffix : hw_suffixes) { for (const auto& suffix : hw_suffixes) {
if (name.find(suffix) != std::string::npos) return false; if (name.find(suffix) != std::string::npos) return false;
@@ -37,7 +37,7 @@ bool is_software_hevc(const std::string &name) {
if (name != "hevc" && name != "libx265") return false; if (name != "hevc" && name != "libx265") return false;
static const char* hw_suffixes[] = { static const char* hw_suffixes[] = {
"nvenc", "amf", "qsv", "vaapi", "rkmpp", "nvenc", "amf", "qsv", "vaapi", "rkmpp",
"v4l2m2m", "videotoolbox", "mediacodec", "_mf" "v4l2m2m", "videotoolbox", "_mf"
}; };
for (const auto& suffix : hw_suffixes) { for (const auto& suffix : hw_suffixes) {
if (name.find(suffix) != std::string::npos) return false; if (name.find(suffix) != std::string::npos) return false;
@@ -100,13 +100,8 @@ void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs,
c->color_primaries = AVCOL_PRI_SMPTE170M; c->color_primaries = AVCOL_PRI_SMPTE170M;
c->color_trc = AVCOL_TRC_SMPTE170M; c->color_trc = AVCOL_TRC_SMPTE170M;
// WebRTC SDP advertises constrained baseline. Keep most hardware and software // WebRTC SDP advertises constrained baseline. Keep hardware and software
// encoders on the same browser-friendly H264 profile. Android MediaCodec is // encoders on the same browser-friendly H264 profile.
// deliberately excluded because older vendor OMX encoders can reject explicit
// profile/level combinations during configure().
if (name.find("mediacodec") != std::string::npos) {
return;
}
if (name.find("h264") != std::string::npos) { if (name.find("h264") != std::string::npos) {
c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE; c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE;
} else if (name.find("hevc") != std::string::npos) { } else if (name.find("hevc") != std::string::npos) {
@@ -310,9 +305,6 @@ bool set_quality(void *priv_data, const std::string &name, int quality) {
break; break;
} }
} }
// Do not force MediaCodec level here. Some Android TV vendor encoders,
// including older Amlogic OMX implementations, reject explicit level values
// even when they support the requested resolution and bitrate.
// libx264 software encoder presets // libx264 software encoder presets
if (is_software_h264(name)) { if (is_software_h264(name)) {
const char* preset = nullptr; const char* preset = nullptr;
@@ -367,7 +359,7 @@ struct CodecOptions {
}; };
bool set_rate_control(AVCodecContext *c, const std::string &name, int rc, bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
int q) { int /*q*/) {
if (name.find("qsv") != std::string::npos) { if (name.find("qsv") != std::string::npos) {
// https://github.com/LizardByte/Sunshine/blob/3e47cd3cc8fd37a7a88be82444ff4f3c0022856b/src/video.cpp#L1635 // https://github.com/LizardByte/Sunshine/blob/3e47cd3cc8fd37a7a88be82444ff4f3c0022856b/src/video.cpp#L1635
c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL; c->strict_std_compliance = FF_COMPLIANCE_UNOFFICIAL;
@@ -375,9 +367,6 @@ bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
std::vector<CodecOptions> codecs = { std::vector<CodecOptions> codecs = {
{"nvenc", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr"}}}, {"nvenc", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr"}}},
{"amf", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr_latency"}}}, {"amf", "rc", {{RC_CBR, "cbr"}, {RC_VBR, "vbr_latency"}}},
{"mediacodec",
"bitrate_mode",
{{RC_CBR, "cbr"}, {RC_VBR, "vbr"}, {RC_CQ, "cq"}}},
// {"videotoolbox", "constant_bit_rate", {{RC_CBR, "1"}}}, // {"videotoolbox", "constant_bit_rate", {{RC_CBR, "1"}}},
}; };
@@ -392,13 +381,6 @@ bool set_rate_control(AVCodecContext *c, const std::string &name, int rc,
it->second + " failed, ret = " + av_err2str(ret)); it->second + " failed, ret = " + av_err2str(ret));
return false; return false;
} }
if (name.find("mediacodec") != std::string::npos) {
if (rc == RC_CQ) {
if (q >= 0 && q <= 51) {
c->global_quality = q;
}
}
}
} }
break; break;
} }
@@ -448,13 +430,6 @@ bool set_others(void *priv_data, const std::string &name) {
return false; return false;
} }
} }
if (name.find("mediacodec") != std::string::npos) {
if ((ret = av_opt_set_int(priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_ERROR(std::string("mediacodec set ndk_codec failed, ret = ") +
av_err2str(ret));
return false;
}
}
// NOTE: Removed idr_interval = INT_MAX for VAAPI. // NOTE: Removed idr_interval = INT_MAX for VAAPI.
// This was disabling automatic keyframe generation. // This was disabling automatic keyframe generation.
// The encoder should respect c->gop_size for keyframe interval. // The encoder should respect c->gop_size for keyframe interval.

View File

@@ -1,4 +1,4 @@
// Minimal FFmpeg RAM MJPEG decoder (RKMPP only) -> NV12 in CPU memory. // FFmpeg RAM decoder with optional RKMPP hardware-frame support.
extern "C" { extern "C" {
#include <libavcodec/avcodec.h> #include <libavcodec/avcodec.h>
@@ -54,9 +54,11 @@ public:
thread_count_ = thread_count > 0 ? thread_count : 1; thread_count_ = thread_count > 0 ? thread_count : 1;
callback_ = callback; callback_ = callback;
#ifdef ONE_KVM_FFMPEG_RKMPP
if (name_.find("rkmpp") != std::string::npos) { if (name_.find("rkmpp") != std::string::npos) {
hw_device_type_ = AV_HWDEVICE_TYPE_RKMPP; hw_device_type_ = AV_HWDEVICE_TYPE_RKMPP;
} }
#endif
} }
~FFmpegRamDecoder() {} ~FFmpegRamDecoder() {}
@@ -137,13 +139,6 @@ public:
av_buffer_unref(&frames_ref); av_buffer_unref(&frames_ref);
} }
if (name_.find("mediacodec") != std::string::npos && c_->priv_data) {
if ((ret = av_opt_set_int(c_->priv_data, "ndk_codec", 1, 0)) < 0) {
LOG_WARN(std::string("mediacodec decoder ndk_codec option failed, ret = ") +
av_err2str(ret));
}
}
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) { if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret)); set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret));
return false; return false;

View File

@@ -11,6 +11,7 @@ extern "C" {
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <string>
#include "common.h" #include "common.h"
@@ -21,6 +22,13 @@ extern "C" {
#include "win.h" #include "win.h"
#endif #endif
static thread_local std::string g_encoder_last_error;
static void set_encoder_last_error(const std::string &message) {
g_encoder_last_error = message;
LOG_ERROR(message);
}
static int calculate_offset_length(int pix_fmt, int height, const int *linesize, static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
int *offset, int *length) { int *offset, int *length) {
switch (pix_fmt) { switch (pix_fmt) {
@@ -122,7 +130,6 @@ public:
AVFrame *frame_ = NULL; AVFrame *frame_ = NULL;
AVPacket *pkt_ = NULL; AVPacket *pkt_ = NULL;
std::string name_; std::string name_;
std::string mc_name_; // for mediacodec
int width_ = 0; int width_ = 0;
int height_ = 0; int height_ = 0;
@@ -145,14 +152,12 @@ public:
AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE; AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE;
AVBufferRef *hw_device_ctx_ = NULL; AVBufferRef *hw_device_ctx_ = NULL;
AVFrame *hw_frame_ = NULL; AVFrame *hw_frame_ = NULL;
AVFrame *borrowed_frame_ = NULL;
FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height, FFmpegRamEncoder(const char *name, int width, int height,
int pixfmt, int align, int fps, int gop, int rc, int quality, int pixfmt, int align, int fps, int gop, int rc, int quality,
int kbs, int q, int thread_count, int gpu, int kbs, int q, int thread_count, int gpu,
RamEncodeCallback callback) { RamEncodeCallback callback) {
name_ = name; name_ = name;
mc_name_ = mc_name ? mc_name : "";
width_ = width; width_ = width;
height_ = height; height_ = height;
pixfmt_ = (AVPixelFormat)pixfmt; pixfmt_ = (AVPixelFormat)pixfmt;
@@ -184,12 +189,13 @@ public:
} }
bool init(int *linesize, int *offset, int *length) { bool init(int *linesize, int *offset, int *length) {
g_encoder_last_error.clear();
const AVCodec *codec = NULL; const AVCodec *codec = NULL;
int ret; int ret;
if (!(codec = avcodec_find_encoder_by_name(name_.c_str()))) { if (!(codec = avcodec_find_encoder_by_name(name_.c_str()))) {
LOG_ERROR(std::string("Codec ") + name_ + " not found"); set_encoder_last_error(std::string("Codec ") + name_ + " not found");
return false; return false;
} }
@@ -252,12 +258,6 @@ public:
LOG_ERROR(std::string("Could not allocate video packet")); LOG_ERROR(std::string("Could not allocate video packet"));
return false; return false;
} }
borrowed_frame_ = av_frame_alloc();
if (!borrowed_frame_) {
LOG_ERROR(std::string("Could not allocate borrowed video frame"));
return false;
}
/* resolution must be a multiple of two */ /* resolution must be a multiple of two */
c_->width = width_; c_->width = width_;
c_->height = height_; c_->height = height_;
@@ -277,19 +277,9 @@ public:
util_encode::set_gpu(c_->priv_data, name_, gpu_); util_encode::set_gpu(c_->priv_data, name_, gpu_);
util_encode::force_hw(c_->priv_data, name_); util_encode::force_hw(c_->priv_data, name_);
util_encode::set_others(c_->priv_data, name_); util_encode::set_others(c_->priv_data, name_);
if (name_.find("mediacodec") != std::string::npos) {
if (mc_name_.length() > 0) {
LOG_INFO(std::string("mediacodec codec_name: ") + mc_name_);
if ((ret = av_opt_set(c_->priv_data, "codec_name", mc_name_.c_str(),
0)) < 0) {
LOG_ERROR(std::string("mediacodec codec_name failed, ret = ") + av_err2str(ret));
}
}
}
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) { if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
LOG_ERROR(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret) + set_encoder_last_error(std::string("avcodec_open2 failed, ret = ") +
", name: " + name_); av_err2str(ret) + ", name: " + name_);
return false; return false;
} }
@@ -307,14 +297,6 @@ public:
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) { int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) {
int ret; int ret;
if (can_borrow_input(length)) {
AVFrame *borrowed = wrap_borrowed_frame(data, length);
if (!borrowed) {
return -1;
}
return do_encode(borrowed, obj, ms);
}
if ((ret = av_frame_make_writable(frame_)) != 0) { if ((ret = av_frame_make_writable(frame_)) != 0) {
LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret)); LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret));
return ret; return ret;
@@ -350,8 +332,6 @@ public:
av_frame_free(&frame_); av_frame_free(&frame_);
if (hw_frame_) if (hw_frame_)
av_frame_free(&hw_frame_); av_frame_free(&hw_frame_);
if (borrowed_frame_)
av_frame_free(&borrowed_frame_);
if (hw_device_ctx_) if (hw_device_ctx_)
av_buffer_unref(&hw_device_ctx_); av_buffer_unref(&hw_device_ctx_);
if (c_) if (c_)
@@ -610,65 +590,6 @@ private:
return 0; return 0;
} }
bool can_borrow_input(int data_length) const {
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
return false;
}
if (name_.find("mediacodec") == std::string::npos) {
return false;
}
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
return data_length >= width_ * height_ * 3 / 2;
case AV_PIX_FMT_YUV420P:
return data_length >= width_ * height_ * 3 / 2;
default:
return false;
}
}
AVFrame *wrap_borrowed_frame(const uint8_t *data, int data_length) {
if (!borrowed_frame_) {
return NULL;
}
av_frame_unref(borrowed_frame_);
borrowed_frame_->format = pixfmt_;
borrowed_frame_->width = width_;
borrowed_frame_->height = height_;
const int y_size = width_ * height_;
const int uv_size = y_size / 4;
switch (pixfmt_) {
case AV_PIX_FMT_NV12:
case AV_PIX_FMT_NV21:
if (data_length < y_size + y_size / 2) {
LOG_ERROR("wrap_borrowed_frame: NV12/NV21 data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_;
break;
case AV_PIX_FMT_YUV420P:
if (data_length < y_size + uv_size * 2) {
LOG_ERROR("wrap_borrowed_frame: YUV420P data length error");
return NULL;
}
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
borrowed_frame_->data[2] = const_cast<uint8_t *>(data + y_size + uv_size);
borrowed_frame_->linesize[0] = width_;
borrowed_frame_->linesize[1] = width_ / 2;
borrowed_frame_->linesize[2] = width_ / 2;
break;
default:
return NULL;
}
return borrowed_frame_;
}
int bytes_per_pixel(int pix_fmt) { int bytes_per_pixel(int pix_fmt) {
switch (pix_fmt) { switch (pix_fmt) {
case AV_PIX_FMT_YUYV422: case AV_PIX_FMT_YUYV422:
@@ -687,14 +608,14 @@ private:
} // namespace } // namespace
extern "C" FFmpegRamEncoder * extern "C" FFmpegRamEncoder *
ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width, ffmpeg_ram_new_encoder(const char *name, int width,
int height, int pixfmt, int align, int fps, int gop, int height, int pixfmt, int align, int fps, int gop,
int rc, int quality, int kbs, int q, int thread_count, int rc, int quality, int kbs, int q, int thread_count,
int gpu, int *linesize, int *offset, int *length, int gpu, int *linesize, int *offset, int *length,
RamEncodeCallback callback) { RamEncodeCallback callback) {
FFmpegRamEncoder *encoder = NULL; FFmpegRamEncoder *encoder = NULL;
try { try {
encoder = new FFmpegRamEncoder(name, mc_name, width, height, pixfmt, align, encoder = new FFmpegRamEncoder(name, width, height, pixfmt, align,
fps, gop, rc, quality, kbs, q, thread_count, fps, gop, rc, quality, kbs, q, thread_count,
gpu, callback); gpu, callback);
if (encoder) { if (encoder) {
@@ -772,3 +693,7 @@ extern "C" void ffmpeg_ram_request_keyframe(FFmpegRamEncoder *encoder) {
LOG_ERROR(std::string("ffmpeg_ram_request_keyframe failed, ") + std::string(e.what())); LOG_ERROR(std::string("ffmpeg_ram_request_keyframe failed, ") + std::string(e.what()));
} }
} }
extern "C" const char *ffmpeg_ram_encoder_last_error(void) {
return g_encoder_last_error.c_str();
}

View File

@@ -13,7 +13,7 @@ typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data,
typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width, typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width,
int height, int pixfmt, const void *obj); int height, int pixfmt, const void *obj);
void *ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width, void *ffmpeg_ram_new_encoder(const char *name, int width,
int height, int pixfmt, int align, int fps, int height, int pixfmt, int align, int fps,
int gop, int rc, int quality, int kbs, int q, int gop, int rc, int quality, int kbs, int q,
int thread_count, int gpu, int *linesize, int thread_count, int gpu, int *linesize,
@@ -31,6 +31,7 @@ int ffmpeg_ram_get_linesize_offset_length(int pix_fmt, int width, int height,
int *length); int *length);
int ffmpeg_ram_set_bitrate(void *encoder, int kbs); int ffmpeg_ram_set_bitrate(void *encoder, int kbs);
void ffmpeg_ram_request_keyframe(void *encoder); void ffmpeg_ram_request_keyframe(void *encoder);
const char *ffmpeg_ram_encoder_last_error(void);
void *ffmpeg_ram_new_decoder(const char *name, int width, int height, void *ffmpeg_ram_new_decoder(const char *name, int width, int height,
int sw_pixfmt, int thread_count, int sw_pixfmt, int thread_count,

View File

@@ -13,7 +13,7 @@ pub enum Driver {
FFMPEG, FFMPEG,
} }
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) { pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::ffi::c_int; use std::ffi::c_int;
@@ -39,8 +39,6 @@ pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
linux_support_amd() == 0, linux_support_amd() == 0,
linux_support_intel() == 0, linux_support_intel() == 0,
); );
#[cfg(target_os = "android")]
return (false, false, false);
#[allow(unreachable_code)] #[allow(unreachable_code)]
(false, false, false) (false, false, false)
} }

View File

@@ -114,7 +114,7 @@ impl Drop for Decoder {
} }
} }
fn last_error_message() -> String { pub fn last_error_message() -> String {
unsafe { unsafe {
let ptr = ffmpeg_ram_last_error(); let ptr = ffmpeg_ram_last_error();
if ptr.is_null() { if ptr.is_null() {

View File

@@ -3,8 +3,9 @@ use crate::{
ffmpeg::{init_av_log, AVPixelFormat}, ffmpeg::{init_av_log, AVPixelFormat},
ffmpeg_ram::{ ffmpeg_ram::{
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encode_packet, ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encode_packet,
ffmpeg_ram_free_encoder, ffmpeg_ram_free_packet, ffmpeg_ram_new_encoder, ffmpeg_ram_encoder_last_error, ffmpeg_ram_free_encoder, ffmpeg_ram_free_packet,
ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS, ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo,
AV_NUM_DATA_POINTERS,
}, },
}; };
#[cfg(feature = "bytes")] #[cfg(feature = "bytes")]
@@ -17,7 +18,7 @@ use std::{
slice, slice,
}; };
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
use crate::common::Driver; use crate::common::Driver;
/// Timeout for encoder test in milliseconds /// Timeout for encoder test in milliseconds
@@ -28,7 +29,6 @@ const PRIORITY_AMF: i32 = 2;
const PRIORITY_RKMPP: i32 = 3; const PRIORITY_RKMPP: i32 = 3;
const PRIORITY_VAAPI: i32 = 4; const PRIORITY_VAAPI: i32 = 4;
const PRIORITY_V4L2M2M: i32 = 5; const PRIORITY_V4L2M2M: i32 = 5;
const PRIORITY_MEDIACODEC: i32 = 2;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct CandidateCodecSpec { struct CandidateCodecSpec {
@@ -95,32 +95,12 @@ fn linux_support_v4l2m2m() -> bool {
false false
} }
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> { fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
use log::debug; use log::debug;
let mut codecs = Vec::new(); let mut codecs = Vec::new();
if cfg!(target_os = "android") {
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "h264_mediacodec",
format: H264,
priority: PRIORITY_MEDIACODEC,
},
);
push_candidate(
&mut codecs,
CandidateCodecSpec {
name: "hevc_mediacodec",
format: H265,
priority: PRIORITY_MEDIACODEC,
},
);
return codecs;
}
let contains = |_vendor: Driver, _format: DataFormat| { let contains = |_vendor: Driver, _format: DataFormat| {
// Without VRAM feature, we can't check SDK availability. // Without VRAM feature, we can't check SDK availability.
// Keep the prefilter coarse and let FFmpeg validation do the real check. // Keep the prefilter coarse and let FFmpeg validation do the real check.
@@ -281,13 +261,7 @@ struct ProbePolicy {
impl ProbePolicy { impl ProbePolicy {
fn for_codec(codec_name: &str) -> Self { fn for_codec(codec_name: &str) -> Self {
if codec_name.contains("mediacodec") { if codec_name.contains("amf") {
Self {
max_attempts: 30,
request_keyframe: true,
accept_any_output: true,
}
} else if codec_name.contains("amf") {
Self { Self {
max_attempts: 5, max_attempts: 5,
request_keyframe: true, request_keyframe: true,
@@ -340,7 +314,8 @@ fn log_failed_probe_attempt(
if frames.is_empty() { if frames.is_empty() {
trace!( trace!(
"Encoder {} test produced no output on attempt {}", "Encoder {} test produced no output on attempt {}",
codec_name, attempt codec_name,
attempt
); );
} else { } else {
debug!( debug!(
@@ -373,7 +348,6 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
let test_ctx = EncodeContext { let test_ctx = EncodeContext {
name: codec.name.clone(), name: codec.name.clone(),
mc_name: codec.mc_name.clone(),
..ctx.clone() ..ctx.clone()
}; };
@@ -418,13 +392,13 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
); );
} }
} }
Err(err) => { Err(err) => {
last_err = Some(err); last_err = Some(err);
warn!( warn!(
"Encoder {} test attempt {} returned error: {}", "Encoder {} test attempt {} returned error: {}",
codec.name, attempt_no, err codec.name, attempt_no, err
); );
} }
} }
} }
@@ -437,20 +411,16 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
); );
false false
} }
Err(_) => { Err(_) => {
warn!("Failed to create encoder {}", codec.name); warn!("Failed to create encoder {}", codec.name);
false false
} }
} }
} }
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) { fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
use log::debug; use log::debug;
if cfg!(target_os = "android") {
return;
}
for fallback in CodecInfo::soft().into_vec() { for fallback in CodecInfo::soft().into_vec() {
if !codecs.iter().any(|codec| codec.format == fallback.format) { if !codecs.iter().any(|codec| codec.format == fallback.format) {
debug!( debug!(
@@ -465,7 +435,6 @@ fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct EncodeContext { pub struct EncodeContext {
pub name: String, pub name: String,
pub mc_name: Option<String>,
pub width: i32, pub width: i32,
pub height: i32, pub height: i32,
pub pixfmt: i32, pub pixfmt: i32,
@@ -550,10 +519,8 @@ impl Encoder {
.unwrap_or("-1".to_owned()) .unwrap_or("-1".to_owned())
.parse() .parse()
.unwrap_or(-1); .unwrap_or(-1);
let mc_name = ctx.mc_name.clone().unwrap_or_default();
let codec = ffmpeg_ram_new_encoder( let codec = ffmpeg_ram_new_encoder(
CString::new(ctx.name.as_str()).map_err(|_| ())?.as_ptr(), CString::new(ctx.name.as_str()).map_err(|_| ())?.as_ptr(),
CString::new(mc_name.as_str()).map_err(|_| ())?.as_ptr(),
ctx.width, ctx.width,
ctx.height, ctx.height,
ctx.pixfmt, ctx.pixfmt,
@@ -573,6 +540,10 @@ impl Encoder {
); );
if codec.is_null() { if codec.is_null() {
let message = encoder_last_error_message();
if !message.is_empty() {
log::error!("ffmpeg_ram_new_encoder failed: {}", message);
}
return Err(()); return Err(());
} }
@@ -698,11 +669,11 @@ impl Encoder {
pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> { pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> {
use log::debug; use log::debug;
if !(cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "android")) { if !(cfg!(windows) || cfg!(target_os = "linux")) {
return vec![]; return vec![];
} }
let mut res = vec![]; let mut res = vec![];
#[cfg(any(windows, target_os = "linux", target_os = "android"))] #[cfg(any(windows, target_os = "linux"))]
let codecs = enumerate_candidate_codecs(&ctx); let codecs = enumerate_candidate_codecs(&ctx);
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) { if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
@@ -736,6 +707,16 @@ impl Encoder {
} }
} }
pub fn encoder_last_error_message() -> String {
unsafe {
let ptr = ffmpeg_ram_encoder_last_error();
if ptr.is_null() {
return String::new();
}
std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string()
}
}
impl Drop for Encoder { impl Drop for Encoder {
fn drop(&mut self) { fn drop(&mut self) {
unsafe { unsafe {

View File

@@ -9,18 +9,12 @@ use std::ffi::c_int;
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs")); include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod decode; pub mod decode;
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder // Provide a small stub on non-ARM builds so dependents can still compile, but decoder
// construction will fail (since the C++ RKMPP decoder isn't built/linked). // construction will fail (since the C++ RKMPP decoder isn't built/linked).
#[cfg(any( #[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))]
not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")),
target_os = "android"
))]
pub mod decode { pub mod decode {
use crate::ffmpeg::AVPixelFormat; use crate::ffmpeg::AVPixelFormat;
@@ -69,8 +63,6 @@ pub enum Priority {
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
pub struct CodecInfo { pub struct CodecInfo {
pub name: String, pub name: String,
#[serde(skip)]
pub mc_name: Option<String>,
pub format: DataFormat, pub format: DataFormat,
pub priority: i32, pub priority: i32,
pub hwdevice: AVHWDeviceType, pub hwdevice: AVHWDeviceType,
@@ -80,7 +72,6 @@ impl Default for CodecInfo {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: Default::default(), name: Default::default(),
mc_name: Default::default(),
format: DataFormat::H264, format: DataFormat::H264,
priority: Default::default(), priority: Default::default(),
hwdevice: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE, hwdevice: AVHWDeviceType::AV_HWDEVICE_TYPE_NONE,
@@ -93,28 +84,24 @@ impl CodecInfo {
match format { match format {
H264 => Some(CodecInfo { H264 => Some(CodecInfo {
name: "libx264".to_owned(), name: "libx264".to_owned(),
mc_name: Default::default(),
format: H264, format: H264,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
H265 => Some(CodecInfo { H265 => Some(CodecInfo {
name: "libx265".to_owned(), name: "libx265".to_owned(),
mc_name: Default::default(),
format: H265, format: H265,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
VP8 => Some(CodecInfo { VP8 => Some(CodecInfo {
name: "libvpx".to_owned(), name: "libvpx".to_owned(),
mc_name: Default::default(),
format: VP8, format: VP8,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,
}), }),
VP9 => Some(CodecInfo { VP9 => Some(CodecInfo {
name: "libvpx-vp9".to_owned(), name: "libvpx-vp9".to_owned(),
mc_name: Default::default(),
format: VP9, format: VP9,
hwdevice: AV_HWDEVICE_TYPE_NONE, hwdevice: AV_HWDEVICE_TYPE_NONE,
priority: Priority::Soft as _, priority: Priority::Soft as _,

View File

@@ -2,10 +2,7 @@
pub mod capture; pub mod capture;
pub mod common; pub mod common;
pub mod ffmpeg; pub mod ffmpeg;
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))]
any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"),
not(target_os = "android")
))]
pub mod ffmpeg_hw; pub mod ffmpeg_hw;
pub mod ffmpeg_ram; pub mod ffmpeg_ram;

View File

@@ -1,52 +0,0 @@
// This file is generated by cargo_embargo.
// Do not modify this file because the changes will be overridden on upgrade.
package {
default_applicable_licenses: ["external_rust_crates_v4l2r_license"],
}
rust_library {
name: "libv4l2r",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
apex_available: [
"//apex_available:platform",
"//apex_available:anyapex",
],
product_available: true,
vendor_available: true,
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}
rust_test {
name: "v4l2r_test_src_lib",
crate_name: "v4l2r",
cargo_env_compat: true,
cargo_pkg_version: "0.0.7",
crate_root: "src/lib.rs",
test_suites: ["general-tests"],
auto_gen_config: true,
edition: "2021",
rustlibs: [
"libbitflags",
"liblog_rust",
"libnix",
"libthiserror",
],
proc_macros: ["libenumn"],
// Bindgen-generated bindings of our local videodev2.h.
srcs: [":libv4l2r_bindgen"],
}

View File

@@ -17,7 +17,3 @@ parts are intentionally removed here so this dependency stays scoped to capture.
`cargo build` generates V4L2 bindings from the vendored Linux UAPI headers in `cargo build` generates V4L2 bindings from the vendored Linux UAPI headers in
`include/`. `include/`.
For Android targets, the build script uses the Android NDK sysroot. Set one of
`ANDROID_NDK_HOME`, `ANDROID_NDK_ROOT`, `NDK_HOME`, `ANDROID_HOME`, or
`ANDROID_SDK_ROOT` if the NDK cannot be found automatically.

View File

@@ -1,7 +1,7 @@
// This file defines the customizations to the bindgen builder used to generate the v4l2r // This file defines the customizations to the bindgen builder used to generate the v4l2r
// bindings. // bindings.
// //
// It is meant to be included from `lib/build.rs` and `android/build.rs`. // It is meant to be included from `build.rs`.
#[derive(Debug)] #[derive(Debug)]
/// Workaround for https://github.com/rust-lang/rust-bindgen/issues/753. /// Workaround for https://github.com/rust-lang/rust-bindgen/issues/753.

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
include!("bindgen.rs"); include!("bindgen.rs");
/// Vendored Linux UAPI include root used for non-Android targets. /// Vendored Linux UAPI include root.
const VENDORED_INCLUDE_DIR: &str = "include"; const VENDORED_INCLUDE_DIR: &str = "include";
/// Wrapper file to use as input of bindgen. /// Wrapper file to use as input of bindgen.
@@ -13,28 +13,16 @@ const WRAPPER_H: &str = "v4l2r_wrapper.h";
const FIX753_H: &str = "fix753.h"; const FIX753_H: &str = "fix753.h";
fn main() { fn main() {
let target = env::var("TARGET").unwrap_or_default(); let include_root =
let is_android = target.contains("android");
let include_root = if is_android {
android_sysroot().join("usr/include")
} else {
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("`CARGO_MANIFEST_DIR` is not set")) PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("`CARGO_MANIFEST_DIR` is not set"))
.join(VENDORED_INCLUDE_DIR) .join(VENDORED_INCLUDE_DIR);
};
let videodev2_h = include_root.join("linux/videodev2.h"); let videodev2_h = include_root.join("linux/videodev2.h");
println!("cargo::rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo::rerun-if-env-changed=NDK_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_HOME");
println!("cargo::rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo::rerun-if-env-changed=CARGO_NDK_PLATFORM");
println!("cargo::rerun-if-changed={}", videodev2_h.display()); println!("cargo::rerun-if-changed={}", videodev2_h.display());
println!("cargo::rerun-if-changed={}", FIX753_H); println!("cargo::rerun-if-changed={}", FIX753_H);
println!("cargo::rerun-if-changed={}", WRAPPER_H); println!("cargo::rerun-if-changed={}", WRAPPER_H);
let mut clang_args = vec![ let clang_args = vec![
format!("-I{}", include_root.display()), format!("-I{}", include_root.display()),
#[cfg(all(feature = "arch64", not(feature = "arch32")))] #[cfg(all(feature = "arch64", not(feature = "arch32")))]
"--target=x86_64-linux-gnu".into(), "--target=x86_64-linux-gnu".into(),
@@ -42,10 +30,6 @@ fn main() {
"--target=i686-linux-gnu".into(), "--target=i686-linux-gnu".into(),
]; ];
if is_android {
clang_args.extend(android_clang_args(&target));
}
let bindings = v4l2r_bindgen_builder(bindgen::Builder::default()) let bindings = v4l2r_bindgen_builder(bindgen::Builder::default())
.header(WRAPPER_H) .header(WRAPPER_H)
.clang_args(clang_args) .clang_args(clang_args)
@@ -57,105 +41,3 @@ fn main() {
.write_to_file(out_path.join("bindings.rs")) .write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!"); .expect("Couldn't write bindings!");
} }
fn android_clang_args(target: &str) -> Vec<String> {
let ndk = android_ndk_home();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for v4l2r bindgen: {other}"),
}
}
fn android_sysroot() -> PathBuf {
android_ndk_home()
.join("toolchains/llvm/prebuilt")
.join(host_tag())
.join("sysroot")
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"v4l2r Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &PathBuf) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &PathBuf) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang resource directory in {}", clang_dir.display()))
}

View File

@@ -1,9 +1,3 @@
#ifdef __ANDROID__
#include <stddef.h>
#include <stdint.h>
#include <sys/types.h>
#endif
#include <linux/videodev2.h> #include <linux/videodev2.h>
#define MARK_FIX_753(name) const unsigned long int Fix753_##name = name; #define MARK_FIX_753(name) const unsigned long int Fix753_##name = name;

View File

@@ -19,17 +19,7 @@ fn main() {
fn generate_bindings(cpp_dir: &Path) { fn generate_bindings(cpp_dir: &Path) {
let ffi_header = cpp_dir.join("yuv_ffi.h"); let ffi_header = cpp_dir.join("yuv_ffi.h");
let mut builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string()); let builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string());
if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() == Some("android") {
println!("cargo:rerun-if-env-changed=ANDROID_NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_NDK_ROOT");
println!("cargo:rerun-if-env-changed=NDK_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_HOME");
println!("cargo:rerun-if-env-changed=ANDROID_SDK_ROOT");
println!("cargo:rerun-if-env-changed=CARGO_NDK_PLATFORM");
builder = builder.clang_args(android_clang_args());
}
builder builder
// YUYV conversions // YUYV conversions
@@ -96,30 +86,6 @@ fn generate_bindings(cpp_dir: &Path) {
} }
fn link_libyuv() { fn link_libyuv() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "android" {
if link_android_libyuv() {
return;
}
if let Some(vcpkg_installed) = vcpkg_installed_root() {
if link_vcpkg(vcpkg_installed) {
return;
}
}
panic!(
"Android libyuv not found!\n\
\n\
Build it with scripts/build-android-libyuv.sh and set:\n\
export ONE_KVM_ANDROID_LIBYUV_ROOT=/path/to/android-libyuv\n\
\n\
Expected layout:\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/include\n\
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/lib/libyuv.a"
);
}
// Try vcpkg first // Try vcpkg first
if let Some(vcpkg_installed) = vcpkg_installed_root() { if let Some(vcpkg_installed) = vcpkg_installed_root() {
if link_vcpkg(vcpkg_installed) { if link_vcpkg(vcpkg_installed) {
@@ -148,217 +114,6 @@ fn link_libyuv() {
); );
} }
fn link_android_libyuv() -> bool {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_ROOT");
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_LIBYUV_STATIC");
let root = match env::var("ONE_KVM_ANDROID_LIBYUV_ROOT")
.ok()
.filter(|path| !path.trim().is_empty())
{
Some(path) => PathBuf::from(path),
None => return false,
};
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let abi = android_abi(&target_arch);
let abi_root = root.join(abi);
let lib_dir = if abi_root.join("lib").exists() {
abi_root.join("lib")
} else {
root.join("lib")
};
let include_dir = if abi_root.join("include").exists() {
abi_root.join("include")
} else {
root.join("include")
};
let static_lib = lib_dir.join("libyuv.a");
let shared_lib = lib_dir.join("libyuv.so");
let use_static = env::var("ONE_KVM_ANDROID_LIBYUV_STATIC")
.or_else(|_| env::var("LIBYUV_STATIC"))
.map(|value| value != "0")
.unwrap_or(true);
if use_static && static_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=yuv");
link_android_libjpeg(&root, abi);
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (static linking)",
root.display()
);
return true;
}
if shared_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=yuv");
println!("cargo:rustc-link-lib=c++_shared");
println!(
"cargo:info=Using Android libyuv from {} (dynamic linking)",
root.display()
);
return true;
}
println!(
"cargo:warning=Android libyuv not found under {} for ABI {} (checked {}, {})",
root.display(),
abi,
static_lib.display(),
shared_lib.display()
);
if !include_dir.exists() {
println!(
"cargo:warning=Android libyuv include directory not found: {}",
include_dir.display()
);
}
false
}
fn link_android_libjpeg(libyuv_root: &Path, abi: &str) {
println!("cargo:rerun-if-env-changed=ONE_KVM_ANDROID_TURBOJPEG_ROOT");
let mut roots = Vec::new();
if let Ok(root) = env::var("ONE_KVM_ANDROID_TURBOJPEG_ROOT") {
if !root.trim().is_empty() {
roots.push(PathBuf::from(root));
}
}
roots.push(libyuv_root.with_file_name("android-turbojpeg"));
for root in roots {
let abi_lib_dir = root.join(abi).join("lib");
let lib_dir = if abi_lib_dir.exists() {
abi_lib_dir
} else {
root.join("lib")
};
let jpeg_lib = lib_dir.join("libjpeg.a");
if jpeg_lib.exists() {
println!("cargo:rustc-link-search=native={}", lib_dir.display());
println!("cargo:rustc-link-lib=static=jpeg");
println!(
"cargo:info=Using Android libjpeg for libyuv MJPEG from {}",
root.display()
);
return;
}
}
println!("cargo:warning=Android libjpeg.a not found; libyuv MJPEG symbols may fail to link");
}
fn android_abi(target_arch: &str) -> &'static str {
match target_arch {
"aarch64" => "arm64-v8a",
"arm" => "armeabi-v7a",
"x86" => "x86",
"x86_64" => "x86_64",
_ => "unknown",
}
}
fn android_clang_args() -> Vec<String> {
let ndk = android_ndk_home();
let target = env::var("TARGET").unwrap_or_default();
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
let sysroot = toolchain.join("sysroot");
let clang_include = toolchain
.join("lib/clang")
.join(clang_version(&toolchain))
.join("include");
let api = env::var("CARGO_NDK_PLATFORM")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(21);
let clang_target = android_clang_target(&target);
vec![
format!("--target={clang_target}"),
format!("--sysroot={}", sysroot.display()),
format!("-D__ANDROID_API__={api}"),
format!("-isystem{}", clang_include.display()),
format!("-isystem{}", sysroot.join("usr/include").display()),
format!(
"-isystem{}",
sysroot.join("usr/include").join(clang_target).display()
),
]
}
fn android_clang_target(target: &str) -> &'static str {
match target {
"aarch64-linux-android" => "aarch64-linux-android",
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
"i686-linux-android" => "i686-linux-android",
"x86_64-linux-android" => "x86_64-linux-android",
other => panic!("unsupported Android target for libyuv bindgen: {other}"),
}
}
fn android_ndk_home() -> PathBuf {
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
if let Ok(value) = env::var(key) {
return PathBuf::from(value);
}
}
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
if let Ok(value) = env::var(key) {
let ndk_dir = PathBuf::from(value).join("ndk");
if let Some(newest) = newest_child_dir(&ndk_dir) {
return newest;
}
}
}
panic!(
"libyuv Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
);
}
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
let mut entries = std::fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
entries.pop()
}
fn host_tag() -> &'static str {
if cfg!(target_os = "linux") {
"linux-x86_64"
} else if cfg!(target_os = "macos") {
"darwin-x86_64"
} else if cfg!(target_os = "windows") {
"windows-x86_64"
} else {
panic!("unsupported host OS for Android NDK");
}
}
fn clang_version(toolchain: &Path) -> String {
let clang_dir = toolchain.join("lib/clang");
let mut entries = std::fs::read_dir(&clang_dir)
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
entries
.pop()
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
}
fn vcpkg_installed_root() -> Option<PathBuf> { fn vcpkg_installed_root() -> Option<PathBuf> {
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
@@ -383,10 +138,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
("linux", "x86_64") => "x64-linux", ("linux", "x86_64") => "x64-linux",
("linux", "aarch64") => "arm64-linux", ("linux", "aarch64") => "arm64-linux",
("linux", "arm") => "arm-linux", ("linux", "arm") => "arm-linux",
("android", "x86_64") => "x64-android",
("android", "x86") => "x86-android",
("android", "aarch64") => "arm64-android",
("android", "arm") => "arm-neon-android",
("windows", "x86_64") => "x64-windows-static", ("windows", "x86_64") => "x64-windows-static",
("windows", "x86") => "x86-windows-static", ("windows", "x86") => "x86-windows-static",
("macos", "x86_64") => "x64-osx", ("macos", "x86_64") => "x64-osx",
@@ -426,8 +177,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os); link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os);
if target_os == "linux" { if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++"); println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
} }
println!("cargo:info=Using libyuv from vcpkg (static linking)"); println!("cargo:info=Using libyuv from vcpkg (static linking)");
} else { } else {
@@ -435,8 +184,6 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
println!("cargo:rustc-link-lib=yuv"); println!("cargo:rustc-link-lib=yuv");
if target_os == "linux" { if target_os == "linux" {
println!("cargo:rustc-link-lib=stdc++"); println!("cargo:rustc-link-lib=stdc++");
} else if target_os == "android" {
println!("cargo:rustc-link-lib=c++_shared");
} }
println!("cargo:info=Using libyuv from vcpkg (dynamic linking)"); println!("cargo:info=Using libyuv from vcpkg (dynamic linking)");
} }

View File

@@ -1,200 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-alsa"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
ALSA_VERSION="${ALSA_VERSION:-1.2.15}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-alsa.sh [options]
Options:
--output <dir> Output root. Default: dist/android-alsa
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_ALSA_ROOT:
<output>/arm64-v8a/include/alsa/asoundlib.h
<output>/arm64-v8a/lib/libasound.so
<output>/arm64-v8a/lib/pkgconfig/alsa.pc
<output>/armeabi-v7a/include/alsa/asoundlib.h
<output>/armeabi-v7a/lib/libasound.so
<output>/armeabi-v7a/lib/pkgconfig/alsa.pc
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-alsa-src"
rm -rf "$SOURCE_DIR"
mkdir -p "${PROJECT_ROOT}/.tmp"
archive="${PROJECT_ROOT}/.tmp/alsa-lib-${ALSA_VERSION}.tar.bz2"
url="https://www.alsa-project.org/files/pub/lib/alsa-lib-${ALSA_VERSION}.tar.bz2"
echo "Downloading ALSA ${ALSA_VERSION}: $url"
curl -fL "$url" -o "$archive"
tar -xjf "$archive" -C "${PROJECT_ROOT}/.tmp"
mv "${PROJECT_ROOT}/.tmp/alsa-lib-${ALSA_VERSION}" "$SOURCE_DIR"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
clean_generated_source_headers() {
rm -f \
"$SOURCE_DIR/include/asoundlib.h" \
"$SOURCE_DIR/include/version.h" \
"$SOURCE_DIR/include/stamp-vh" \
"$SOURCE_DIR/include/alsa"
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/alsa-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
clean_generated_source_headers
if [[ ! -x "$SOURCE_DIR/configure" ]]; then
(
cd "$SOURCE_DIR"
autoreconf -fi
)
fi
(
cd "$build_dir"
pcm_plugins="copy linear route mulaw alaw adpcm rate plug multi file null empty meter hooks lfloat ladspa asym iec958 softvol extplug ioplug mmap_emul"
ctl_plugins="remap ext"
ac_cv_header_sys_shm_h=no \
"$SOURCE_DIR/configure" \
--host="$HOST_TRIPLE" \
--prefix="$prefix" \
--enable-shared \
--disable-static \
--disable-python \
--with-pcm-plugins="$pcm_plugins" \
--with-ctl-plugins="$ctl_plugins" \
--disable-doc \
--disable-oss \
--disable-seq \
--disable-ucm \
--disable-topology \
--disable-rawmidi \
--disable-hwdep \
--disable-usb \
--disable-firewire \
--disable-instr \
--disable-alisp
make -j"$JOBS"
make install
)
mkdir -p "$prefix/lib/pkgconfig"
cat > "$prefix/lib/pkgconfig/alsa.pc" <<EOF
prefix=\${pcfiledir}/../..
exec_prefix=\${prefix}
libdir=\${exec_prefix}/lib
includedir=\${prefix}/include
Name: alsa
Description: ALSA sound library
Version: 1.2.15
Libs: -L\${libdir} -lasound
Cflags: -I\${includedir}
EOF
echo "Built ALSA for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_ALSA_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,291 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-ffmpeg-mediacodec"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
FFMPEG_ROCKCHIP_REV="${FFMPEG_ROCKCHIP_REV:-40c412daccf08164493da0de990eb99a8948116b}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-ffmpeg-mediacodec.sh [options]
Options:
--output <dir> Output root. Default: dist/android-ffmpeg-mediacodec
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_FFMPEG_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib
Example:
scripts/build-android-ffmpeg-mediacodec.sh --output /opt/one-kvm/android-ffmpeg
export ONE_KVM_ANDROID_FFMPEG_ROOT=/opt/one-kvm/android-ffmpeg
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-ffmpeg-check/src/ffmpeg-rockchip"
rm -rf "$SOURCE_DIR"
mkdir -p "$(dirname "$SOURCE_DIR")"
repo_url="https://github.com/nyanmisaka/ffmpeg-rockchip.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/nyanmisaka/ffmpeg-rockchip.git"
fi
echo "Cloning FFmpeg source: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "$FFMPEG_ROCKCHIP_REV"
git checkout --detach FETCH_HEAD
)
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
patch_android_ffmpeg_mjpeg_mediacodec() {
local avcodec_dir="${SOURCE_DIR}/libavcodec"
local configure_file="${SOURCE_DIR}/configure"
local mediacodecdec="${avcodec_dir}/mediacodecdec.c"
local allcodecs="${avcodec_dir}/allcodecs.c"
local makefile="${avcodec_dir}/Makefile"
python3 - "$mediacodecdec" "$allcodecs" "$configure_file" "$makefile" <<'PY'
from pathlib import Path
import sys
mediacodecdec, allcodecs, configure_file, makefile = map(Path, sys.argv[1:])
def replace_once(path: Path, old: str, new: str) -> None:
text = path.read_text()
if new in text:
return
if old not in text:
raise SystemExit(f"patch anchor not found in {path}: {old!r}")
path.write_text(text.replace(old, new, 1))
replace_once(
mediacodecdec,
"CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
"CONFIG_MJPEG_MEDIACODEC_DECODER || \\\n"
" CONFIG_MPEG2_MEDIACODEC_DECODER || \\\n",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MJPEG:\n"
" codec_mime = \"video/mjpeg\";\n\n"
" ret = common_set_extradata(avctx, format);\n"
" if (ret < 0)\n"
" goto done;\n"
" break;\n"
"#endif\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
" case AV_CODEC_ID_MPEG2VIDEO:",
)
replace_once(
mediacodecdec,
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
"#if CONFIG_MJPEG_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mjpeg, \"MJPEG\", AV_CODEC_ID_MJPEG, NULL)\n"
"#endif\n\n"
"#if CONFIG_MPEG2_MEDIACODEC_DECODER\n"
"DECLARE_MEDIACODEC_VDEC(mpeg2, \"MPEG-2\", AV_CODEC_ID_MPEG2VIDEO, NULL)",
)
replace_once(
allcodecs,
"extern const FFCodec ff_mjpeg_cuvid_decoder;",
"extern const FFCodec ff_mjpeg_cuvid_decoder;\n"
"extern const FFCodec ff_mjpeg_mediacodec_decoder;",
)
replace_once(
configure_file,
'mjpeg_cuvid_decoder_deps="cuvid"',
'mjpeg_cuvid_decoder_deps="cuvid"\n'
'mjpeg_mediacodec_decoder_deps="mediacodec"',
)
replace_once(
makefile,
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
"OBJS-$(CONFIG_MJPEG_MEDIACODEC_DECODER) += mediacodecdec.o\n"
"OBJS-$(CONFIG_MJPEG_RKMPP_DECODER)",
)
PY
}
abi_arch() {
case "$1" in
arm64-v8a) echo "aarch64" ;;
armeabi-v7a) echo "arm" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_cpu() {
case "$1" in
arm64-v8a) echo "armv8-a" ;;
armeabi-v7a) echo "armv7-a" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
abi_target() {
case "$1" in
arm64-v8a) echo "aarch64-linux-android" ;;
armeabi-v7a) echo "armv7a-linux-androideabi" ;;
*) fail "Unsupported ABI: $1" ;;
esac
}
build_one() {
local abi="$1"
local arch cpu target prefix build_dir cc cxx ar ranlib strip extra_cflags extra_ldflags
arch="$(abi_arch "$abi")"
cpu="$(abi_cpu "$abi")"
target="$(abi_target "$abi")"
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/ffmpeg-android-build/${abi}"
cc="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang"
cxx="${TOOLCHAIN}/bin/${target}${ANDROID_API}-clang++"
ar="${TOOLCHAIN}/bin/llvm-ar"
ranlib="${TOOLCHAIN}/bin/llvm-ranlib"
strip="${TOOLCHAIN}/bin/llvm-strip"
extra_cflags="-fPIC"
extra_ldflags=""
if [[ "$abi" == "armeabi-v7a" ]]; then
extra_cflags="${extra_cflags} -march=armv7-a -mfloat-abi=softfp -mfpu=neon"
extra_ldflags="${extra_ldflags} -Wl,--fix-cortex-a8"
fi
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
"${SOURCE_DIR}/configure" \
--prefix="$prefix" \
--target-os=android \
--arch="$arch" \
--cpu="$cpu" \
--cc="$cc" \
--cxx="$cxx" \
--ar="$ar" \
--ranlib="$ranlib" \
--strip="$strip" \
--cross-prefix="${TOOLCHAIN}/bin/llvm-" \
--sysroot="${TOOLCHAIN}/sysroot" \
--enable-cross-compile \
--enable-static \
--disable-shared \
--disable-programs \
--disable-doc \
--disable-avdevice \
--disable-avformat \
--disable-avfilter \
--disable-swscale \
--disable-swresample \
--disable-postproc \
--disable-network \
--disable-everything \
--disable-hwaccels \
--disable-cuda-llvm \
--disable-v4l2-m2m \
--disable-vulkan \
--enable-pthreads \
--enable-jni \
--enable-mediacodec \
--enable-decoder=mjpeg_mediacodec \
--enable-decoder=mjpeg \
--enable-encoder=h264_mediacodec \
--enable-encoder=hevc_mediacodec \
--enable-parser=mjpeg \
--enable-bsf=h264_metadata \
--enable-bsf=hevc_metadata \
--enable-protocol=file \
--extra-cflags="$extra_cflags" \
--extra-ldflags="$extra_ldflags"
make -j"$JOBS"
make install
)
echo "Built FFmpeg MediaCodec for ${abi}: ${prefix}"
}
patch_android_ffmpeg_mjpeg_mediacodec
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_FFMPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,184 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-libyuv"
JPEG_ROOT="${ONE_KVM_ANDROID_TURBOJPEG_ROOT:-${PROJECT_ROOT}/dist/android-turbojpeg}"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBYUV_REV="${LIBYUV_REV:-957f295ea946cbbd13fcfc46e7066f2efa801233}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-libyuv.sh [options]
Options:
--output <dir> Output root. Default: dist/android-libyuv
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
--jpeg-root <dir> Android libjpeg root. Defaults to ONE_KVM_ANDROID_TURBOJPEG_ROOT
or dist/android-turbojpeg when present. Enables libyuv HAVE_JPEG.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_LIBYUV_ROOT:
<output>/arm64-v8a/include
<output>/arm64-v8a/lib/libyuv.a
<output>/armeabi-v7a/include
<output>/armeabi-v7a/lib/libyuv.a
Example:
scripts/build-android-libyuv.sh --output /opt/one-kvm/android-libyuv
export ONE_KVM_ANDROID_LIBYUV_ROOT=/opt/one-kvm/android-libyuv
cd android && ./gradlew :app:assembleDebug
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
--jpeg-root)
JPEG_ROOT="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-libyuv-src"
rm -rf "$SOURCE_DIR"
repo_url="https://github.com/lemenkov/libyuv.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/lemenkov/libyuv.git"
fi
echo "Cloning libyuv source: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "$LIBYUV_REV"
git checkout --detach FETCH_HEAD
)
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir jpeg_include jpeg_library
local -a jpeg_args
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/libyuv-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
jpeg_include="$JPEG_ROOT/$abi/include"
jpeg_library="$JPEG_ROOT/$abi/lib/libjpeg.a"
jpeg_args=()
if [[ -f "$jpeg_library" && -f "$jpeg_include/jpeglib.h" ]]; then
jpeg_args=(
-DJPEG_FOUND=TRUE
-DJPEG_INCLUDE_DIR="$jpeg_include"
-DJPEG_LIBRARY="$jpeg_library"
-DCMAKE_C_FLAGS="-DHAVE_JPEG"
-DCMAKE_CXX_FLAGS="-DHAVE_JPEG"
)
else
echo "Warning: Android libjpeg not found for ${abi}; libyuv MJPEG APIs will be disabled." >&2
echo " Checked: $jpeg_library and $jpeg_include/jpeglib.h" >&2
fi
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DBUILD_SHARED_LIBS=OFF \
-DUNIT_TEST=OFF \
-DTEST=OFF \
"${jpeg_args[@]}"
cmake --build "$build_dir" --target yuv --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
if [[ -f "$build_dir/libyuv.a" ]]; then
cp "$build_dir/libyuv.a" "$prefix/lib/libyuv.a"
elif [[ -f "$build_dir/lib/libyuv.a" ]]; then
cp "$build_dir/lib/libyuv.a" "$prefix/lib/libyuv.a"
else
fail "Built libyuv.a was not found under: $build_dir"
fi
cp -R "$SOURCE_DIR/include/." "$prefix/include/"
echo "Built libyuv for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_LIBYUV_ROOT="${OUTPUT_DIR}"
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${JPEG_ROOT}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-opus"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
OPUS_VERSION="${OPUS_VERSION:-1.5.2}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-opus.sh [options]
Options:
--output <dir> Output root. Default: dist/android-opus
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_OPUS_ROOT:
<output>/arm64-v8a/include/opus/opus.h
<output>/arm64-v8a/lib/libopus.so
<output>/armeabi-v7a/include/opus/opus.h
<output>/armeabi-v7a/lib/libopus.so
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-opus-src"
rm -rf "$SOURCE_DIR"
mkdir -p "$SOURCE_DIR"
tarball="${PROJECT_ROOT}/.tmp/opus-${OPUS_VERSION}.tar.gz"
url="https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz"
curl -fsSL "$url" -o "$tarball"
tar -xzf "$tarball" -C "$SOURCE_DIR" --strip-components=1
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
TOOLCHAIN="${NDK_ROOT}/toolchains/llvm/prebuilt/${HOST_TAG}"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir
case "$abi" in
arm64-v8a | armeabi-v7a) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/opus-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
(
cd "$build_dir"
case "$abi" in
arm64-v8a)
export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
export HOST_TRIPLE="aarch64-linux-android"
;;
armeabi-v7a)
export CC="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang"
export CXX="${TOOLCHAIN}/bin/armv7a-linux-androideabi${ANDROID_API}-clang++"
export HOST_TRIPLE="arm-linux-androideabi"
;;
esac
export AR="${TOOLCHAIN}/bin/llvm-ar"
export RANLIB="${TOOLCHAIN}/bin/llvm-ranlib"
export STRIP="${TOOLCHAIN}/bin/llvm-strip"
export CFLAGS="-fPIC"
export CXXFLAGS="-fPIC"
export LDFLAGS=""
"$SOURCE_DIR/configure" \
--prefix="$prefix" \
--host="$HOST_TRIPLE" \
--disable-static \
--enable-shared \
--disable-doc \
--disable-extra-programs \
--with-pic
make -j"$JOBS"
make install
)
echo "Built Opus for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_OPUS_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,173 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_DIR="${PROJECT_ROOT}/dist/android-turbojpeg"
ANDROID_API="${ANDROID_API:-21}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
BUILD_ABIS="arm64-v8a armeabi-v7a"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
LIBJPEG_TURBO_VERSION="${LIBJPEG_TURBO_VERSION:-3.1.4.1}"
usage() {
cat <<'EOF'
Usage:
scripts/build-android-turbojpeg.sh [options]
Options:
--output <dir> Output root. Default: dist/android-turbojpeg
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
--api <level> Android API level. Default: 21.
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
-h, --help Show this help.
The output layout is compatible with ONE_KVM_ANDROID_TURBOJPEG_ROOT:
<output>/arm64-v8a/include/turbojpeg.h
<output>/arm64-v8a/lib/libturbojpeg.a
<output>/arm64-v8a/include/jpeglib.h
<output>/arm64-v8a/lib/libjpeg.a
<output>/armeabi-v7a/include/turbojpeg.h
<output>/armeabi-v7a/lib/libturbojpeg.a
<output>/armeabi-v7a/include/jpeglib.h
<output>/armeabi-v7a/lib/libjpeg.a
EOF
}
fail() {
echo "Error: $*" >&2
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--output)
OUTPUT_DIR="${2:-}"
shift 2
;;
--ndk)
NDK_ROOT="${2:-}"
shift 2
;;
--api)
ANDROID_API="${2:-}"
shift 2
;;
--abis)
BUILD_ABIS="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown argument: $1"
;;
esac
done
[[ -n "$NDK_ROOT" ]] || fail "--ndk or ANDROID_NDK_HOME/ANDROID_NDK_ROOT is required"
[[ -d "$NDK_ROOT/toolchains/llvm/prebuilt" ]] || fail "Invalid NDK root: $NDK_ROOT"
SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-turbojpeg-src"
rm -rf "$SOURCE_DIR"
repo_url="https://github.com/libjpeg-turbo/libjpeg-turbo.git"
if [[ "${CHINAMIRRO:-0}" == "1" ]]; then
repo_url="${GH_PROXY:-https://gh-proxy.com}"
repo_url="${repo_url%/}/https://github.com/libjpeg-turbo/libjpeg-turbo.git"
fi
echo "Cloning libjpeg-turbo ${LIBJPEG_TURBO_VERSION}: $repo_url"
git init "$SOURCE_DIR"
(
cd "$SOURCE_DIR"
git remote add origin "$repo_url"
git fetch --depth 1 origin "refs/tags/$LIBJPEG_TURBO_VERSION"
git checkout --detach FETCH_HEAD
)
SOURCE_DIR="$(cd "$SOURCE_DIR" && pwd)"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
HOST_TAG="$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64"
ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake"
[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE"
normalize_abis() {
printf '%s\n' "$BUILD_ABIS" | tr ',' ' '
}
build_one() {
local abi="$1"
local prefix build_dir lib_path
case "$abi" in
arm64-v8a | armeabi-v7a | x86 | x86_64) ;;
*) fail "Unsupported ABI: $abi" ;;
esac
prefix="${OUTPUT_DIR}/${abi}"
build_dir="${PROJECT_ROOT}/.tmp/turbojpeg-android-build/${abi}"
rm -rf "$build_dir"
mkdir -p "$build_dir" "$prefix"
cmake -S "$SOURCE_DIR" -B "$build_dir" \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN_FILE" \
-DANDROID_ABI="$abi" \
-DANDROID_PLATFORM="android-${ANDROID_API}" \
-DANDROID_STL=c++_shared \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$prefix" \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
-DCMAKE_C_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DCMAKE_CXX_FLAGS="-DANDROID -Dstderr=__sF+2" \
-DENABLE_SHARED=OFF \
-DENABLE_STATIC=ON \
-DWITH_TURBOJPEG=ON \
-DWITH_JAVA=OFF \
-DWITH_12BIT=OFF \
-DWITH_ARITH_DEC=ON \
-DWITH_ARITH_ENC=ON
cmake --build "$build_dir" --target turbojpeg-static jpeg-static --parallel "$JOBS"
mkdir -p "$prefix/lib" "$prefix/include"
lib_path="$build_dir/libturbojpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libturbojpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libturbojpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libturbojpeg.a"
lib_path="$build_dir/libjpeg.a"
if [[ ! -f "$lib_path" ]]; then
lib_path="$build_dir/lib/libjpeg.a"
fi
[[ -f "$lib_path" ]] || fail "Built libjpeg.a was not found under: $build_dir"
cp "$lib_path" "$prefix/lib/libjpeg.a"
cp "$SOURCE_DIR/src/turbojpeg.h" "$prefix/include/turbojpeg.h"
cp "$SOURCE_DIR/src/jerror.h" "$prefix/include/jerror.h"
cp "$SOURCE_DIR/src/jmorecfg.h" "$prefix/include/jmorecfg.h"
cp "$SOURCE_DIR/src/jpeglib.h" "$prefix/include/jpeglib.h"
cp "$build_dir/jconfig.h" "$prefix/include/jconfig.h"
echo "Built TurboJPEG for ${abi}: ${prefix}"
}
for abi in $(normalize_abis); do
build_one "$abi"
done
cat <<EOF
Done.
Use this when building the Android APK:
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${OUTPUT_DIR}"
cd android && ./gradlew :app:assembleDebug
EOF

View File

@@ -1,11 +1,7 @@
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
#[path = "capture_linux.rs"] #[path = "capture_linux.rs"]
mod imp; mod imp;
#[cfg(feature = "android")]
#[path = "capture_android.rs"]
mod imp;
#[cfg(windows)] #[cfg(windows)]
#[path = "capture_windows.rs"] #[path = "capture_windows.rs"]
mod imp; mod imp;

View File

@@ -1,292 +0,0 @@
use alsa::pcm::{Access, Format, Frames, HwParams};
use alsa::{Direction, ValueOr, PCM};
use bytes::Bytes;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, watch, Mutex};
use tracing::{debug, info};
use crate::audio::device::AudioDeviceInfo;
use crate::error::{AppError, Result};
use crate::utils::LogThrottler;
use crate::{error_throttled, warn_throttled};
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub device_name: String,
pub sample_rate: u32,
pub channels: u32,
pub frame_size: u32,
pub buffer_frames: u32,
pub period_frames: u32,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
device_name: String::new(),
sample_rate: 48_000,
channels: 2,
frame_size: 960,
buffer_frames: 4096,
period_frames: 960,
}
}
}
impl AudioConfig {
pub fn for_device(device: &AudioDeviceInfo) -> Self {
Self {
device_name: device.name.clone(),
..Default::default()
}
}
pub fn bytes_per_sample(&self) -> u32 {
2 * self.channels
}
pub fn bytes_per_frame(&self) -> usize {
(self.frame_size * self.bytes_per_sample()) as usize
}
}
#[derive(Debug, Clone)]
pub struct AudioFrame {
pub data: Bytes,
pub sample_rate: u32,
pub channels: u32,
pub samples: u32,
pub sequence: u64,
pub timestamp: Instant,
}
impl AudioFrame {
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
let bps = 2 * channels;
Self {
samples: data.len() as u32 / bps,
data,
sample_rate,
channels,
sequence,
timestamp: Instant::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureState {
Stopped,
Running,
Error,
}
pub struct AudioCapturer {
config: AudioConfig,
state: Arc<watch::Sender<CaptureState>>,
state_rx: watch::Receiver<CaptureState>,
frame_tx: broadcast::Sender<AudioFrame>,
stop_flag: Arc<AtomicBool>,
sequence: Arc<AtomicU64>,
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
log_throttler: LogThrottler,
}
impl AudioCapturer {
pub fn new(config: AudioConfig) -> Self {
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
let (frame_tx, _) = broadcast::channel(16);
Self {
config,
state: Arc::new(state_tx),
state_rx,
frame_tx,
stop_flag: Arc::new(AtomicBool::new(false)),
sequence: Arc::new(AtomicU64::new(0)),
capture_handle: Mutex::new(None),
log_throttler: LogThrottler::with_secs(5),
}
}
pub fn state(&self) -> CaptureState {
*self.state_rx.borrow()
}
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
self.state_rx.clone()
}
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
self.frame_tx.subscribe()
}
pub async fn start(&self) -> Result<()> {
if self.state() == CaptureState::Running {
return Ok(());
}
debug!(
"Starting audio capture on {} at {}Hz {}ch",
self.config.device_name, self.config.sample_rate, self.config.channels
);
self.stop_flag.store(false, Ordering::SeqCst);
let config = self.config.clone();
let state = self.state.clone();
let frame_tx = self.frame_tx.clone();
let stop_flag = self.stop_flag.clone();
let sequence = self.sequence.clone();
let log_throttler = self.log_throttler.clone();
let handle = tokio::task::spawn_blocking(move || {
let result = run_capture(
&config,
&state,
&frame_tx,
&stop_flag,
&sequence,
&log_throttler,
);
if let Err(e) = result {
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
let _ = state.send(CaptureState::Error);
} else {
let _ = state.send(CaptureState::Stopped);
}
});
*self.capture_handle.lock().await = Some(handle);
Ok(())
}
pub async fn stop(&self) -> Result<()> {
info!("Stopping audio capture");
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.capture_handle.lock().await.take() {
let _ = handle.await;
}
let _ = self.state.send(CaptureState::Stopped);
Ok(())
}
pub fn is_running(&self) -> bool {
self.state() == CaptureState::Running
}
}
fn run_capture(
config: &AudioConfig,
state: &watch::Sender<CaptureState>,
frame_tx: &broadcast::Sender<AudioFrame>,
stop_flag: &AtomicBool,
sequence: &AtomicU64,
log_throttler: &LogThrottler,
) -> Result<()> {
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
AppError::AudioError(format!(
"Failed to open audio device {}: {}",
config.device_name, e
))
})?;
{
let hwp = HwParams::any(&pcm)
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
hwp.set_channels(config.channels)
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
hwp.set_format(Format::s16())
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
hwp.set_access(Access::RWInterleaved)
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
hwp.set_buffer_size_near(config.buffer_frames as Frames)
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
pcm.hw_params(&hwp)
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
}
let hw_now = pcm.hw_params_current().map_err(|e| {
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
})?;
let actual_rate = hw_now
.get_rate()
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
let actual_ch = hw_now
.get_channels()
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
if actual_rate != 48_000 {
return Err(AppError::AudioError(format!(
"Audio capture requires 48000 Hz; device is {} Hz",
actual_rate
)));
}
if actual_ch != 2 {
return Err(AppError::AudioError(format!(
"Audio capture requires 2 channels (stereo); device has {}",
actual_ch
)));
}
debug!("Audio capture: 48000 Hz, 2 ch");
pcm.prepare()
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
let _ = state.send(CaptureState::Running);
let period_frames = pcm
.hw_params_current()
.ok()
.and_then(|h| h.get_period_size().ok())
.map(|f| f as usize)
.unwrap_or(1024)
.max(256);
let buf_frames = period_frames.saturating_mul(4).max(2048);
let io = pcm
.io_i16()
.map_err(|e| AppError::AudioError(format!("Failed to get PCM IO: {}", e)))?;
let mut buffer = vec![0i16; buf_frames * 2];
let mut next_log = Instant::now();
while !stop_flag.load(Ordering::SeqCst) {
match io.readi(&mut buffer[..period_frames * 2]) {
Ok(frames_read) => {
if frames_read == 0 {
continue;
}
let samples = frames_read * 2;
let data = Bytes::copy_from_slice(bytemuck::cast_slice(&buffer[..samples]));
let seq = sequence.fetch_add(1, Ordering::SeqCst);
let frame = AudioFrame::new_interleaved(data, 2, 48_000, seq);
let _ = frame_tx.send(frame);
if next_log.elapsed().as_secs() >= 5 {
debug!("Captured audio frame {} ({} samples)", seq, samples / 2);
next_log = Instant::now();
}
}
Err(err) => {
warn_throttled!(
log_throttler,
"alsa_read",
"ALSA read error on {}: {}",
config.device_name,
err
);
let _ = pcm.try_recover(err, false);
}
}
}
let _ = pcm.drain();
Ok(())
}

View File

@@ -1,11 +1,7 @@
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
#[path = "device_linux.rs"] #[path = "device_linux.rs"]
mod imp; mod imp;
#[cfg(feature = "android")]
#[path = "device_android.rs"]
mod imp;
#[cfg(windows)] #[cfg(windows)]
#[path = "device_windows.rs"] #[path = "device_windows.rs"]
mod imp; mod imp;

View File

@@ -1,185 +0,0 @@
use alsa::pcm::HwParams;
use alsa::{Direction, PCM};
use serde::Serialize;
use tracing::{debug, info, warn};
use crate::error::{AppError, Result};
#[derive(Debug, Clone, Serialize)]
pub struct AudioDeviceInfo {
pub name: String,
pub description: String,
pub card_index: i32,
pub device_index: i32,
pub sample_rates: Vec<u32>,
pub channels: Vec<u32>,
pub is_capture: bool,
pub is_hdmi: bool,
pub usb_bus: Option<String>,
}
fn get_usb_bus_info(card_index: i32) -> Option<String> {
if card_index < 0 {
return None;
}
let device_path = format!("/sys/class/sound/card{}/device", card_index);
let link_target = std::fs::read_link(&device_path).ok()?;
let link_str = link_target.to_string_lossy();
for component in link_str.split('/') {
if component.contains('-') && !component.contains(':') {
if component
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return Some(component.to_string());
}
}
}
None
}
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
enumerate_audio_devices_with_current(None)
}
pub fn enumerate_audio_devices_with_current(
current_device: Option<&str>,
) -> Result<Vec<AudioDeviceInfo>> {
let mut devices = Vec::new();
for card_result in alsa::card::Iter::new() {
let card = match card_result {
Ok(card) => card,
Err(err) => {
debug!("Error iterating card: {}", err);
continue;
}
};
let card_index = card.get_index();
let card_name = card.get_name().unwrap_or_else(|_| "Unknown".to_string());
let card_longname = card.get_longname().unwrap_or_else(|_| card_name.clone());
debug!("Found audio card {}: {}", card_index, card_longname);
let long_lower = card_longname.to_lowercase();
let is_hdmi = long_lower.contains("hdmi")
|| long_lower.contains("capture")
|| long_lower.contains("usb");
let usb_bus = get_usb_bus_info(card_index);
for device_index in 0..8 {
let device_name = format!("hw:{},{}", card_index, device_index);
let is_current_device = current_device == Some(device_name.as_str());
let mut push_info =
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
devices.push(AudioDeviceInfo {
name: device_name.clone(),
description,
card_index,
device_index,
sample_rates,
channels,
is_capture: true,
is_hdmi,
usb_bus: usb_bus.clone(),
});
};
match PCM::new(&device_name, Direction::Capture, false) {
Ok(pcm) => {
let (sample_rates, channels) = query_device_caps(&pcm);
if !sample_rates.is_empty() && !channels.is_empty() {
push_info(
sample_rates,
channels,
format!("{} - Device {}", card_longname, device_index),
);
}
}
Err(_) if is_current_device => {
debug!(
"Device {} is busy (in use by us), adding with default caps",
device_name
);
push_info(
vec![44_100, 48_000],
vec![2],
format!("{} - Device {} (in use)", card_longname, device_index),
);
}
Err(_) => {}
}
}
}
info!("Found {} audio capture devices", devices.len());
Ok(devices)
}
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
let hwp = match HwParams::any(pcm) {
Ok(h) => h,
Err(_) => return (vec![], vec![]),
};
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
let mut supported_rates = Vec::new();
for rate in &common_rates {
if hwp.test_rate(*rate).is_ok() {
supported_rates.push(*rate);
}
}
let mut supported_channels = Vec::new();
for ch in 1..=8 {
if hwp.test_channels(ch).is_ok() {
supported_channels.push(ch);
}
}
(supported_rates, supported_channels)
}
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
let devices = enumerate_audio_devices()?;
if devices.is_empty() {
return Err(AppError::AudioError(
"No audio capture devices found".to_string(),
));
}
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
for device in &devices {
if !device.sample_rates.contains(&48_000) || !device.channels.contains(&2) {
continue;
}
if device.is_hdmi {
info!("Selected HDMI audio device: {}", device.description);
return Ok(device.clone());
}
if first_48k_stereo.is_none() {
first_48k_stereo = Some(device);
}
}
if let Some(device) = first_48k_stereo {
info!("Selected audio device: {}", device.description);
return Ok(device.clone());
}
let device = devices.into_iter().next().unwrap();
warn!(
"Using fallback audio device: {} (may not support optimal settings)",
device.description
);
Ok(device)
}

View File

@@ -176,18 +176,9 @@ fn get_meminfo() -> MemInfo {
} }
fn get_network_addresses() -> Vec<NetworkAddress> { fn get_network_addresses() -> Vec<NetworkAddress> {
#[cfg(target_os = "android")] get_network_addresses_ifaddrs()
{
return get_network_addresses_android();
}
#[cfg(not(target_os = "android"))]
{
get_network_addresses_ifaddrs()
}
} }
#[cfg(not(target_os = "android"))]
fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> { fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
let all_addrs = match nix::ifaddrs::getifaddrs() { let all_addrs = match nix::ifaddrs::getifaddrs() {
Ok(addrs) => addrs, Ok(addrs) => addrs,
@@ -260,101 +251,6 @@ fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
addresses addresses
} }
#[cfg(target_os = "android")]
fn get_network_addresses_android() -> Vec<NetworkAddress> {
let net_dir = match std::fs::read_dir("/sys/class/net") {
Ok(dir) => dir,
Err(_) => return Vec::new(),
};
let mut addresses = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in net_dir.flatten() {
let iface_name = match entry.file_name().into_string() {
Ok(name) => name,
Err(_) => continue,
};
if iface_name == "lo" {
continue;
}
let operstate_path = entry.path().join("operstate");
let is_up = std::fs::read_to_string(&operstate_path)
.map(|s| s.trim() == "up")
.unwrap_or(false);
if !is_up {
continue;
}
let Some(ip) = android_ipv4_for_interface(&iface_name) else {
continue;
};
if ip.is_loopback() || ip.is_unspecified() {
continue;
}
let ip_str = ip.to_string();
if seen.insert((iface_name.clone(), ip_str.clone())) {
addresses.push(NetworkAddress {
interface: iface_name,
ip: ip_str,
});
}
}
addresses
}
#[cfg(target_os = "android")]
fn android_ipv4_for_interface(iface_name: &str) -> Option<std::net::Ipv4Addr> {
use std::ffi::CString;
use std::mem::{size_of, zeroed};
let name = CString::new(iface_name).ok()?;
if name.as_bytes().len() >= libc::IFNAMSIZ {
return None;
}
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
if fd < 0 {
return None;
}
let mut request: libc::ifreq = zeroed();
std::ptr::copy_nonoverlapping(
name.as_ptr(),
request.ifr_name.as_mut_ptr(),
name.as_bytes_with_nul().len(),
);
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
let result = libc::ioctl(fd, request_code, &mut request);
libc::close(fd);
if result < 0 {
return None;
}
let sockaddr = request.ifr_ifru.ifru_addr;
if sockaddr.sa_family as libc::c_int != libc::AF_INET {
return None;
}
let mut storage = [0u8; size_of::<libc::sockaddr_in>()];
std::ptr::copy_nonoverlapping(
&sockaddr as *const libc::sockaddr as *const u8,
storage.as_mut_ptr(),
size_of::<libc::sockaddr>(),
);
let sockaddr_in = &*(storage.as_ptr() as *const libc::sockaddr_in);
Some(std::net::Ipv4Addr::from(u32::from_be(
sockaddr_in.sin_addr.s_addr,
)))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes}; use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};

View File

@@ -1,68 +1,65 @@
//! Core library for One-KVM (IPKVM: capture, HID, OTG, streaming, Web UI glue). //! Core library for One-KVM (IPKVM: capture, HID, OTG, streaming, Web UI glue).
#[cfg(not(any(feature = "android", unix, windows)))] #[cfg(not(any(target_os = "linux", windows)))]
compile_error!("One-KVM supports Linux and Windows targets only."); compile_error!("One-KVM supports Linux and Windows targets only.");
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod runtime;
#[cfg(any(feature = "android", feature = "desktop"))]
pub mod atx; pub mod atx;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod audio; pub mod audio;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod auth; pub mod auth;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod computer_use; pub mod computer_use;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod config; pub mod config;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod db; pub mod db;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod diagnostics; pub mod diagnostics;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod error; pub mod error;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod events; pub mod events;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod extensions; pub mod extensions;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod hid; pub mod hid;
#[cfg(all(unix, any(feature = "android", feature = "desktop")))] #[cfg(all(unix, feature = "desktop"))]
pub mod msd; pub mod msd;
#[cfg(all(unix, any(feature = "android", feature = "desktop")))] #[cfg(all(unix, feature = "desktop"))]
pub mod otg; pub mod otg;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod platform; pub mod platform;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod redfish; pub mod redfish;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod rtsp; pub mod rtsp;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod rustdesk; pub mod rustdesk;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod state; pub mod state;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod stream; pub mod stream;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod stream_encoder; pub mod stream_encoder;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod update; pub mod update;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod utils; pub mod utils;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod video; pub mod video;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod vnc; pub mod vnc;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod web; pub mod web;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod webrtc; pub mod webrtc;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod secrets { pub mod secrets {
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs")); include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
} }
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub use error::{AppError, Result}; pub use error::{AppError, Result};

View File

@@ -65,7 +65,12 @@ struct CliArgs {
address: Option<String>, address: Option<String>,
/// HTTP port (overrides database config) /// HTTP port (overrides database config)
#[arg(short = 'p', long, value_name = "PORT")] #[arg(
short = 'p',
long = "port",
visible_alias = "http-port",
value_name = "PORT"
)]
http_port: Option<u16>, http_port: Option<u16>,
/// HTTPS port (overrides database config) /// HTTPS port (overrides database config)
@@ -84,7 +89,7 @@ struct CliArgs {
#[arg(long, value_name = "FILE", requires = "ssl_cert")] #[arg(long, value_name = "FILE", requires = "ssl_cert")]
ssl_key: Option<PathBuf>, ssl_key: Option<PathBuf>,
/// Data directory path (default: /etc/one-kvm, or the executable directory on Windows) /// Data directory path
#[arg(short = 'd', long, value_name = "DIR")] #[arg(short = 'd', long, value_name = "DIR")]
data_dir: Option<PathBuf>, data_dir: Option<PathBuf>,
@@ -668,9 +673,11 @@ async fn main() -> anyhow::Result<()> {
let mut shutdown_rx = shutdown_tx.subscribe(); let mut shutdown_rx = shutdown_tx.subscribe();
async move { async move {
tokio::select! { tokio::select! {
result = tokio::signal::ctrl_c() => { result = shutdown_signal() => {
result.expect("Failed to install CTRL+C handler"); if let Err(e) = result {
tracing::info!("Shutdown signal received"); tracing::error!("Failed while waiting for shutdown signal: {}", e);
}
tracing::info!("SIGINT or SIGTERM received");
ShutdownAction::Exit ShutdownAction::Exit
} }
request = shutdown_rx.recv() => { request = shutdown_rx.recv() => {
@@ -794,6 +801,24 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm") PathBuf::from("/etc/one-kvm")
} }
#[cfg(unix)]
async fn shutdown_signal() -> anyhow::Result<()> {
use tokio::signal::unix::{signal, SignalKind};
let mut terminate = signal(SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result?,
_ = terminate.recv() => {},
}
Ok(())
}
#[cfg(not(unix))]
async fn shutdown_signal() -> anyhow::Result<()> {
tokio::signal::ctrl_c().await?;
Ok(())
}
async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> { async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> {
let db_path = data_dir.join("one-kvm.db"); let db_path = data_dir.join("one-kvm.db");
let db = DatabasePool::new(&db_path).await?; let db = DatabasePool::new(&db_path).await?;
@@ -1207,6 +1232,11 @@ async fn cleanup(state: &Arc<AppState>) {
} }
} }
#[cfg(unix)]
if let Err(e) = state.otg_service.shutdown().await {
tracing::warn!("Failed to shutdown OTG: {}", e);
}
if let Some(atx) = state.atx.write().await.as_mut() { if let Some(atx) = state.atx.write().await.as_mut() {
if let Err(e) = atx.shutdown().await { if let Err(e) = atx.shutdown().await {
tracing::warn!("Failed to shutdown ATX: {}", e); tracing::warn!("Failed to shutdown ATX: {}", e);

View File

@@ -114,6 +114,28 @@ impl MsdController {
} }
pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> { pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
self.mount_image_in_slot(image, cdrom, read_only, None)
.await
}
pub async fn mount_image_at_lun(
&self,
image: &ImageInfo,
cdrom: bool,
read_only: bool,
lun: u8,
) -> Result<()> {
self.mount_image_in_slot(image, cdrom, read_only, Some(lun))
.await
}
async fn mount_image_in_slot(
&self,
image: &ImageInfo,
cdrom: bool,
read_only: bool,
requested_lun: Option<u8>,
) -> Result<()> {
let _op_guard = self.operation_lock.write().await; let _op_guard = self.operation_lock.write().await;
let mut state = self.state.write().await; let mut state = self.state.write().await;
let previous_state = state.clone(); let previous_state = state.clone();
@@ -136,8 +158,7 @@ impl MsdController {
return Err(AppError::BadRequest("Image is already mounted".to_string())); return Err(AppError::BadRequest("Image is already mounted".to_string()));
} }
let lun = Self::lowest_free_lun(&state) let lun = Self::select_lun(&state, requested_lun)?;
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
let media = MountedMedia::image(lun, image, cdrom, read_only); let media = MountedMedia::image(lun, image, cdrom, read_only);
if let Err(e) = self.configure_media(&media).await { if let Err(e) = self.configure_media(&media).await {
@@ -250,6 +271,27 @@ impl MsdController {
.find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun)) .find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun))
} }
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
let Some(lun) = requested_lun else {
return Self::lowest_free_lun(state)
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()));
};
if lun >= state.disk_mode.capacity() {
return Err(AppError::BadRequest(format!(
"Media slot {} is outside the current disk mode capacity",
lun + 1
)));
}
if state.mounted_media.iter().any(|media| media.lun == lun) {
return Err(AppError::BadRequest(format!(
"Media slot {} is already occupied",
lun + 1
)));
}
Ok(lun)
}
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) { fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
state.disk_mode = disk_mode; state.disk_mode = disk_mode;
state.mounted_media.clear(); state.mounted_media.clear();
@@ -326,14 +368,20 @@ impl MsdController {
pub async fn unmount_image(&self, image_id: &str) -> Result<()> { pub async fn unmount_image(&self, image_id: &str) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id) self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
.await .await
.map(|_| ())
} }
pub async fn unmount_drive(&self) -> Result<()> { pub async fn unmount_drive(&self) -> Result<()> {
self.unmount_media(|media| media.kind == MountedMediaKind::Drive) self.unmount_media(|media| media.kind == MountedMediaKind::Drive)
.await .await
.map(|_| ())
} }
async fn unmount_media<F>(&self, predicate: F) -> Result<()> pub async fn unmount_lun(&self, lun: u8) -> Result<bool> {
self.unmount_media(|media| media.lun == lun).await
}
async fn unmount_media<F>(&self, predicate: F) -> Result<bool>
where where
F: Fn(&MountedMedia) -> bool, F: Fn(&MountedMedia) -> bool,
{ {
@@ -342,7 +390,7 @@ impl MsdController {
let mut state = self.state.write().await; let mut state = self.state.write().await;
let Some(index) = state.mounted_media.iter().position(predicate) else { let Some(index) = state.mounted_media.iter().position(predicate) else {
debug!("Requested media was not mounted, skipping unmount"); debug!("Requested media was not mounted, skipping unmount");
return Ok(()); return Ok(false);
}; };
let media = state.mounted_media[index].clone(); let media = state.mounted_media[index].clone();
@@ -355,7 +403,7 @@ impl MsdController {
self.mark_device_info_dirty().await; self.mark_device_info_dirty().await;
Ok(()) Ok(true)
} }
async fn configure_media(&self, media: &MountedMedia) -> Result<()> { async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
@@ -710,6 +758,29 @@ mod tests {
assert_eq!(MsdController::lowest_free_lun(&state), Some(2)); assert_eq!(MsdController::lowest_free_lun(&state), Some(2));
} }
#[test]
fn explicit_lun_selection_rejects_occupied_and_out_of_range_slots() {
let temp_dir = TempDir::new().unwrap();
let image_path = temp_dir.path().join("test.img");
std::fs::write(&image_path, b"img").unwrap();
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
let mut state = MsdState::default();
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
state
.mounted_media
.push(MountedMedia::image(3, &image, false, true));
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
assert!(MsdController::select_lun(&state, Some(3))
.unwrap_err()
.to_string()
.contains("already occupied"));
assert!(MsdController::select_lun(&state, Some(8))
.unwrap_err()
.to_string()
.contains("outside"));
}
#[test] #[test]
fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() { fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();

View File

@@ -13,7 +13,11 @@ pub const DEFAULT_USB_BCD_DEVICE: u16 = 0x0100;
pub const USB_BCD_USB: u16 = 0x0200; pub const USB_BCD_USB: u16 = 0x0200;
pub fn is_configfs_available() -> bool { pub fn is_configfs_available() -> bool {
Path::new(CONFIGFS_PATH).exists() configfs_path().exists()
}
pub fn configfs_path() -> &'static Path {
Path::new(CONFIGFS_PATH)
} }
/// Loads `libcomposite` if needed; does not mount configfs. /// Loads `libcomposite` if needed; does not mount configfs.

View File

@@ -3,8 +3,8 @@ use std::path::PathBuf;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use super::configfs::{ use super::configfs::{
create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file, configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID, remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
DEFAULT_USB_VENDOR_ID, USB_BCD_USB, DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
}; };
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS}; use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
@@ -65,7 +65,7 @@ impl OtgGadgetManager {
max_endpoints: u8, max_endpoints: u8,
descriptor: GadgetDescriptor, descriptor: GadgetDescriptor,
) -> Self { ) -> Self {
let gadget_path = PathBuf::from(CONFIGFS_PATH).join(gadget_name); let gadget_path = configfs_path().join(gadget_name);
let config_path = gadget_path.join("configs/c.1"); let config_path = gadget_path.join("configs/c.1");
Self { Self {
@@ -166,9 +166,10 @@ impl OtgGadgetManager {
debug!("Setting up OTG USB Gadget: {}", self.gadget_name); debug!("Setting up OTG USB Gadget: {}", self.gadget_name);
if !Self::is_available() { if !Self::is_available() {
return Err(AppError::Internal( return Err(AppError::Internal(format!(
"ConfigFS not available. Is it mounted at /sys/kernel/config?".to_string(), "ConfigFS not available at {}",
)); configfs_path().display()
)));
} }
if self.gadget_exists() { if self.gadget_exists() {

View File

@@ -286,7 +286,10 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
); );
} }
let gadget_root = std::path::Path::new("/sys/kernel/config/usb_gadget"); let gadget_root = crate::otg::configfs::configfs_path();
let configfs_mount = gadget_root
.parent()
.unwrap_or_else(|| std::path::Path::new("/sys/kernel/config"));
let configfs_mounted = std::fs::read_to_string("/proc/mounts") let configfs_mounted = std::fs::read_to_string("/proc/mounts")
.ok() .ok()
.map(|mounts| { .map(|mounts| {
@@ -295,7 +298,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
let _src = parts.next(); let _src = parts.next();
let mount_point = parts.next(); let mount_point = parts.next();
let fs_type = parts.next(); let fs_type = parts.next();
mount_point == Some("/sys/kernel/config") && fs_type == Some("configfs") mount_point == configfs_mount.to_str() && fs_type == Some("configfs")
}) })
}) })
.unwrap_or(false); .unwrap_or(false);
@@ -310,7 +313,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Info, OtgSelfCheckLevel::Info,
"Check configfs mount status", "Check configfs mount status",
None::<String>, None::<String>,
Some("/sys/kernel/config"), Some(configfs_mount.display().to_string()),
); );
} else { } else {
gadget_config_ok = false; gadget_config_ok = false;
@@ -320,8 +323,11 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
false, false,
OtgSelfCheckLevel::Error, OtgSelfCheckLevel::Error,
"Check configfs mount status", "Check configfs mount status",
Some("Try: mount -t configfs none /sys/kernel/config"), Some(format!(
Some("/sys/kernel/config"), "Try: mount -t configfs none {}",
configfs_mount.display()
)),
Some(configfs_mount.display().to_string()),
); );
} }
@@ -331,9 +337,9 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
"usb_gadget_dir_exists", "usb_gadget_dir_exists",
true, true,
OtgSelfCheckLevel::Info, OtgSelfCheckLevel::Info,
"Check /sys/kernel/config/usb_gadget access", format!("Check {} access", gadget_root.display()),
None::<String>, None::<String>,
Some("/sys/kernel/config/usb_gadget"), Some(gadget_root.display().to_string()),
); );
} else { } else {
gadget_config_ok = false; gadget_config_ok = false;
@@ -342,9 +348,9 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
"usb_gadget_dir_exists", "usb_gadget_dir_exists",
false, false,
OtgSelfCheckLevel::Error, OtgSelfCheckLevel::Error,
"Check /sys/kernel/config/usb_gadget access", format!("Check {} access", gadget_root.display()),
Some("Ensure configfs and USB gadget support are enabled"), Some("Ensure configfs and USB gadget support are enabled"),
Some("/sys/kernel/config/usb_gadget"), Some(gadget_root.display().to_string()),
); );
} }
@@ -426,7 +432,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Info, OtgSelfCheckLevel::Info,
"Check for other gadget services", "Check for other gadget services",
None::<String>, None::<String>,
Some("/sys/kernel/config/usb_gadget"), Some(gadget_root.display().to_string()),
); );
} else { } else {
push_otg_check( push_otg_check(
@@ -436,7 +442,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Warn, OtgSelfCheckLevel::Warn,
"Check for other gadget services", "Check for other gadget services",
Some("Potential UDC contention with one-kvm; check other OTG services"), Some("Potential UDC contention with one-kvm; check other OTG services"),
Some("/sys/kernel/config/usb_gadget"), Some(gadget_root.display().to_string()),
); );
} }
@@ -621,7 +627,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Info, OtgSelfCheckLevel::Info,
"Check UDC binding conflicts", "Check UDC binding conflicts",
None::<String>, None::<String>,
Some("/sys/kernel/config/usb_gadget/*/UDC"), Some(format!("{}/*/UDC", gadget_root.display())),
); );
} else { } else {
push_otg_check( push_otg_check(
@@ -631,7 +637,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
OtgSelfCheckLevel::Error, OtgSelfCheckLevel::Error,
"Check UDC binding conflicts", "Check UDC binding conflicts",
Some("Stop other OTG services or switch one-kvm to an idle UDC"), Some("Stop other OTG services or switch one-kvm to an idle UDC"),
Some("/sys/kernel/config/usb_gadget/*/UDC"), Some(format!("{}/*/UDC", gadget_root.display())),
); );
} }
} }

View File

@@ -1,44 +0,0 @@
//! Android Amlogic platform capabilities.
use super::{FeatureCapability, PlatformCapabilities, PlatformMode};
#[cfg(feature = "android")]
#[allow(dead_code)]
fn _keep_android_bionic_ifaddrs_shim_linked() {
let _ = crate::platform::android_bionic::freeifaddrs
as unsafe extern "C" fn(*mut crate::platform::android_bionic::ifaddrs);
let _ = crate::platform::android_bionic::getifaddrs
as unsafe extern "C" fn(*mut *mut crate::platform::android_bionic::ifaddrs) -> i32;
}
pub fn capabilities() -> PlatformCapabilities {
#[cfg(feature = "android")]
_keep_android_bionic_ifaddrs_shim_linked();
PlatformCapabilities {
mode: PlatformMode::AndroidAmlogic,
mode_label: PlatformMode::AndroidAmlogic.label(),
video_capture: FeatureCapability::available(["v4l2_uvc"])
.with_selected_backend(Some("v4l2_uvc".to_string())),
encoder: FeatureCapability::available(["ffmpeg_mediacodec_h264", "mjpeg"])
.with_selected_backend(Some(
if cfg!(feature = "android-mediacodec") {
"ffmpeg_mediacodec_h264"
} else {
"mjpeg"
}
.to_string(),
)),
hid: FeatureCapability::available(["otg_configfs", "ch9329", "none"]),
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
msd: FeatureCapability::available(["otg_configfs"]),
otg: FeatureCapability::available(["configfs"]),
audio: FeatureCapability::available(["alsa", "opus"])
.with_selected_backend(Some("alsa".to_string())),
rustdesk: FeatureCapability::available(["builtin"]),
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
diagnostics: FeatureCapability::available(["android_linux"]),
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
service_installation: FeatureCapability::available(["android_foreground_service"]),
}
}

View File

@@ -1,175 +0,0 @@
#![allow(clippy::missing_safety_doc)]
use std::ffi::CString;
use std::mem::{size_of, zeroed};
use std::os::raw::{c_char, c_int, c_uint, c_void};
#[repr(C)]
pub struct ifaddrs {
pub ifa_next: *mut ifaddrs,
pub ifa_name: *mut c_char,
pub ifa_flags: c_uint,
pub ifa_addr: *mut libc::sockaddr,
pub ifa_netmask: *mut libc::sockaddr,
pub ifa_ifu: *mut libc::sockaddr,
pub ifa_data: *mut c_void,
}
#[repr(C)]
struct AddrNode {
ifa: ifaddrs,
name: CString,
addr: libc::sockaddr_in,
next: *mut AddrNode,
}
fn sockaddr_to_ipv4(addr: libc::sockaddr) -> Option<std::net::Ipv4Addr> {
if addr.sa_family as c_int != libc::AF_INET {
return None;
}
unsafe {
let sin = &*(&addr as *const libc::sockaddr as *const libc::sockaddr_in);
Some(std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)))
}
}
fn query_ipv4(iface_name: &str) -> Option<libc::sockaddr_in> {
let name = CString::new(iface_name).ok()?;
if name.as_bytes().len() >= libc::IFNAMSIZ {
return None;
}
unsafe {
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
if fd < 0 {
return None;
}
let mut request: libc::ifreq = zeroed();
std::ptr::copy_nonoverlapping(
name.as_ptr(),
request.ifr_name.as_mut_ptr(),
name.as_bytes_with_nul().len(),
);
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
let rc = libc::ioctl(fd, request_code, &mut request);
libc::close(fd);
if rc < 0 {
return None;
}
let addr = request.ifr_ifru.ifru_addr;
if addr.sa_family as c_int != libc::AF_INET {
return None;
}
let mut sin: libc::sockaddr_in = zeroed();
std::ptr::copy_nonoverlapping(
&addr as *const libc::sockaddr as *const u8,
&mut sin as *mut libc::sockaddr_in as *mut u8,
size_of::<libc::sockaddr_in>(),
);
Some(sin)
}
}
#[no_mangle]
pub unsafe extern "C" fn getifaddrs(addrs: *mut *mut ifaddrs) -> c_int {
if addrs.is_null() {
return -1;
}
*addrs = std::ptr::null_mut();
let net_dir = match std::fs::read_dir("/sys/class/net") {
Ok(dir) => dir,
Err(_) => return -1,
};
let mut head: *mut AddrNode = std::ptr::null_mut();
let mut tail: *mut AddrNode = std::ptr::null_mut();
for entry in net_dir.flatten() {
let iface_name = match entry.file_name().into_string() {
Ok(name) => name,
Err(_) => continue,
};
if iface_name == "lo" {
continue;
}
let operstate_path = entry.path().join("operstate");
let is_up = std::fs::read_to_string(&operstate_path)
.map(|s| s.trim() == "up")
.unwrap_or(false);
if !is_up {
continue;
}
let Some(addr) = query_ipv4(&iface_name) else {
continue;
};
let ip = sockaddr_to_ipv4(unsafe {
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr)
});
if ip
.map(|ip| ip.is_loopback() || ip.is_unspecified())
.unwrap_or(true)
{
continue;
}
let name = match CString::new(iface_name) {
Ok(name) => name,
Err(_) => continue,
};
let mut node = Box::new(AddrNode {
ifa: ifaddrs {
ifa_next: std::ptr::null_mut(),
ifa_name: std::ptr::null_mut(),
ifa_flags: 0,
ifa_addr: std::ptr::null_mut(),
ifa_netmask: std::ptr::null_mut(),
ifa_ifu: std::ptr::null_mut(),
ifa_data: std::ptr::null_mut(),
},
name,
addr,
next: std::ptr::null_mut(),
});
node.ifa.ifa_name = node.name.as_ptr() as *mut c_char;
node.ifa.ifa_addr = &mut node.addr as *mut libc::sockaddr_in as *mut libc::sockaddr;
node.ifa.ifa_ifu = std::ptr::null_mut();
node.ifa.ifa_netmask = std::ptr::null_mut();
node.ifa.ifa_flags = (libc::IFF_UP | libc::IFF_RUNNING) as c_uint;
let raw = Box::into_raw(node);
if head.is_null() {
head = raw;
} else {
(*tail).next = raw;
(*tail).ifa.ifa_next = raw as *mut ifaddrs;
}
tail = raw;
}
*addrs = if head.is_null() {
std::ptr::null_mut()
} else {
head as *mut ifaddrs
};
0
}
#[no_mangle]
pub unsafe extern "C" fn freeifaddrs(addrs: *mut ifaddrs) {
let mut current = addrs as *mut AddrNode;
while !current.is_null() {
let next = (*current).next;
drop(Box::from_raw(current));
current = next;
}
}

View File

@@ -5,16 +5,13 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PlatformMode { pub enum PlatformMode {
AndroidAmlogic,
Linux, Linux,
Windows, Windows,
} }
impl PlatformMode { impl PlatformMode {
pub const fn current() -> Self { pub const fn current() -> Self {
if cfg!(feature = "android") { if cfg!(windows) {
Self::AndroidAmlogic
} else if cfg!(windows) {
Self::Windows Self::Windows
} else { } else {
Self::Linux Self::Linux
@@ -23,7 +20,6 @@ impl PlatformMode {
pub const fn label(self) -> &'static str { pub const fn label(self) -> &'static str {
match self { match self {
Self::AndroidAmlogic => "Android Amlogic",
Self::Linux => "Linux", Self::Linux => "Linux",
Self::Windows => "Windows", Self::Windows => "Windows",
} }
@@ -86,15 +82,11 @@ pub struct PlatformCapabilities {
impl PlatformCapabilities { impl PlatformCapabilities {
pub fn current() -> Self { pub fn current() -> Self {
#[cfg(feature = "android")]
{
return crate::platform::android::capabilities();
}
#[cfg(windows)] #[cfg(windows)]
{ {
return crate::platform::windows::capabilities(); return crate::platform::windows::capabilities();
} }
#[cfg(all(unix, not(feature = "android")))] #[cfg(target_os = "linux")]
{ {
return crate::platform::linux::capabilities(); return crate::platform::linux::capabilities();
} }

View File

@@ -1,72 +1,21 @@
use crate::config::AppConfig; use crate::config::AppConfig;
#[cfg(windows)] #[cfg(windows)]
use crate::config::AtxDriverType; use crate::config::AtxDriverType;
#[cfg(any(windows, all(unix, feature = "android")))] #[cfg(windows)]
use crate::config::HidBackend; use crate::config::HidBackend;
pub fn apply(config: &mut AppConfig) { pub fn apply(config: &mut AppConfig) {
#[cfg(not(any(windows, all(unix, feature = "android"))))] #[cfg(not(windows))]
{ {
let _ = config; let _ = config;
} }
#[cfg(all(unix, feature = "android"))]
{
apply_android(config);
}
#[cfg(windows)] #[cfg(windows)]
{ {
apply_windows(config); apply_windows(config);
} }
} }
#[cfg(all(unix, feature = "android"))]
fn apply_android(config: &mut AppConfig) {
let detected_udc = crate::otg::configfs::find_udc();
if config
.hid
.otg_udc
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
config.hid.otg_udc = detected_udc;
}
let otg_available = config.hid.otg_udc.is_some();
if !config.initialized && otg_available {
config.hid.backend = HidBackend::Otg;
} else if config.hid.backend == HidBackend::Ch9329
&& config.hid.ch9329_port == "/dev/ttyUSB0"
&& !std::path::Path::new(&config.hid.ch9329_port).exists()
&& otg_available
{
config.hid.backend = HidBackend::Otg;
}
if !config.initialized {
config.audio.enabled = false;
config.audio.device.clear();
config.atx.enabled = false;
config.rustdesk.enabled = false;
config.rtsp.enabled = false;
config.redfish.enabled = false;
}
config
.video
.device
.get_or_insert_with(|| "auto".to_string());
config
.video
.format
.get_or_insert_with(|| "MJPEG".to_string());
config.web.bind_address = "0.0.0.0".to_string();
config.web.bind_addresses = vec!["0.0.0.0".to_string()];
}
#[cfg(windows)] #[cfg(windows)]
fn apply_windows(config: &mut AppConfig) { fn apply_windows(config: &mut AppConfig) {
config.msd.enabled = false; config.msd.enabled = false;

View File

@@ -1,9 +1,5 @@
//! Platform selection and capability reporting. //! Platform selection and capability reporting.
#[cfg(feature = "android")]
pub mod android;
#[cfg(feature = "android")]
pub mod android_bionic;
pub mod capabilities; pub mod capabilities;
pub mod defaults; pub mod defaults;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

View File

@@ -4,7 +4,7 @@ mod event;
mod managers; mod managers;
mod session; mod session;
mod systems; mod systems;
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
mod virtual_media; mod virtual_media;
use axum::{ use axum::{
@@ -196,7 +196,7 @@ pub fn create_redfish_router(state: Arc<AppState>) -> Router {
.merge(account::router(state.clone())) .merge(account::router(state.clone()))
.merge(event::router(state.clone())); .merge(event::router(state.clone()));
#[cfg(all(unix, not(feature = "android")))] #[cfg(unix)]
let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone())); let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone()));
let redfish_routes = redfish_routes.layer(middleware::from_fn_with_state( let redfish_routes = redfish_routes.layer(middleware::from_fn_with_state(

View File

@@ -1,789 +0,0 @@
//! Android service runtime.
//!
//! Android is treated as a packaged Linux distribution: the APK/Java layer only
//! starts and stops this runtime, while the Rust side builds the same AppState
//! and Axum router used by the desktop service.
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use rustls::crypto::{ring, CryptoProvider};
use tokio::runtime::Runtime;
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::atx::AtxController;
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
use crate::auth::{SessionStore, UserStore};
use crate::computer_use::ComputerUseManager;
use crate::config::{self, AppConfig, ConfigStore};
use crate::db::DatabasePool;
use crate::events::EventBus;
use crate::extensions::ExtensionManager;
use crate::hid::{HidBackendType, HidController};
use crate::msd::MsdController;
use crate::otg::OtgService;
use crate::rtsp::RtspService;
use crate::rustdesk::RustDeskService;
use crate::state::{AppState, ShutdownAction};
use crate::stream_encoder::encoder_type_to_backend;
use crate::update::UpdateService;
use crate::utils::bind_tcp_listener;
use crate::video::codec_constraints::{
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
StreamCodecConstraints,
};
use crate::video::format::{PixelFormat, Resolution};
use crate::video::{Streamer, VideoStreamManager};
use crate::vnc::VncService;
use crate::web;
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
#[derive(Debug, Clone)]
pub struct AndroidRuntimeConfig {
pub data_dir: String,
pub bind_address: String,
pub port: u16,
}
struct RuntimeHandle {
stop_tx: oneshot::Sender<()>,
join: JoinHandle<()>,
}
static HANDLE: OnceLock<Mutex<Option<RuntimeHandle>>> = OnceLock::new();
fn handle_slot() -> &'static Mutex<Option<RuntimeHandle>> {
HANDLE.get_or_init(|| Mutex::new(None))
}
pub fn start(config: AndroidRuntimeConfig) -> Result<String, String> {
init_logging();
let mut slot = handle_slot()
.lock()
.map_err(|_| "runtime lock poisoned".to_string())?;
if slot.is_some() {
return Ok(status());
}
let (stop_tx, stop_rx) = oneshot::channel();
let config_for_thread = config.clone();
let join = std::thread::Builder::new()
.name("one-kvm-android-runtime".to_string())
.spawn(move || {
if let Err(err) = run_runtime(config_for_thread, stop_rx) {
tracing::error!("One-KVM Android runtime exited: {}", err);
}
})
.map_err(|err| format!("failed to spawn runtime: {err}"))?;
*slot = Some(RuntimeHandle { stop_tx, join });
Ok(format!(
"One-KVM Android runtime starting on http://{}:{}",
config.bind_address, config.port
))
}
pub fn run_foreground(config: AndroidRuntimeConfig) -> Result<(), String> {
init_logging();
let (_stop_tx, stop_rx) = oneshot::channel();
run_runtime(config, stop_rx)
}
pub fn init_rustls_provider() {
ensure_rustls_provider();
}
pub fn stop() -> String {
let handle = match handle_slot().lock() {
Ok(mut slot) => slot.take(),
Err(_) => return "runtime lock poisoned".to_string(),
};
let Some(handle) = handle else {
return "One-KVM Android runtime is not running".to_string();
};
let _ = handle.stop_tx.send(());
match handle.join.join() {
Ok(()) => "One-KVM Android runtime stopped".to_string(),
Err(_) => "One-KVM Android runtime stopped after panic".to_string(),
}
}
pub fn status() -> String {
match handle_slot().lock() {
Ok(slot) if slot.is_some() => "One-KVM Android runtime running".to_string(),
Ok(_) => "One-KVM Android runtime stopped".to_string(),
Err(_) => "runtime lock poisoned".to_string(),
}
}
fn run_runtime(config: AndroidRuntimeConfig, stop_rx: oneshot::Receiver<()>) -> Result<(), String> {
ensure_rustls_provider();
let runtime = Runtime::new().map_err(|err| format!("failed to create tokio runtime: {err}"))?;
runtime.block_on(async move { run_async(config, stop_rx).await })
}
async fn run_async(
config: AndroidRuntimeConfig,
stop_rx: oneshot::Receiver<()>,
) -> Result<(), String> {
let (db, config_store, app_config) =
load_runtime_config(&PathBuf::from(&config.data_dir), &config).await?;
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
let state = build_app_state(
PathBuf::from(&config.data_dir),
db,
config_store,
app_config,
shutdown_tx.clone(),
)
.await?;
let app = web::create_router(state.clone());
let listener = bind_android_listener(&config.bind_address, config.port)?;
let local_addr = listener
.local_addr()
.map_err(|err| format!("failed to get listener address: {err}"))?;
tracing::info!(
"Starting One-KVM desktop router on Android at http://{}",
local_addr
);
let listener = tokio::net::TcpListener::from_std(listener)
.map_err(|err| format!("failed to create tokio listener: {err}"))?;
let server = axum::serve(listener, app);
let shutdown_signal = {
let mut shutdown_rx = shutdown_tx.subscribe();
async move {
tokio::select! {
_ = stop_rx => {
tracing::info!("Android stop request received");
let _ = shutdown_tx.send(ShutdownAction::Exit);
}
request = shutdown_rx.recv() => {
match request {
Ok(action) => {
tracing::info!("Android shutdown request received: {:?}", action);
}
Err(err) => {
tracing::warn!("Android shutdown request channel closed: {}", err);
}
}
}
}
}
};
tokio::select! {
result = server => {
if let Err(err) = result {
tracing::error!("Android HTTP server error: {}", err);
}
}
_ = shutdown_signal => {}
}
cleanup(&state).await;
Ok(())
}
async fn load_runtime_config(
data_dir: &Path,
runtime_config: &AndroidRuntimeConfig,
) -> Result<(DatabasePool, ConfigStore, AppConfig), String> {
tokio::fs::create_dir_all(data_dir)
.await
.map_err(|err| format!("failed to create data dir {}: {err}", data_dir.display()))?;
let db_path = data_dir.join("one-kvm.db");
let db = DatabasePool::new(&db_path)
.await
.map_err(|err| format!("failed to open database {}: {err}", db_path.display()))?;
db.init_schema()
.await
.map_err(|err| format!("failed to initialize database schema: {err}"))?;
let config_store = ConfigStore::new(db.clone_pool())
.map_err(|err| format!("failed to create config store: {err}"))?;
config_store
.load()
.await
.map_err(|err| format!("failed to load config: {err}"))?;
let mut config = (*config_store.get()).clone();
config.apply_platform_defaults();
config.web.bind_address = runtime_config.bind_address.clone();
config.web.bind_addresses = vec![runtime_config.bind_address.clone()];
config.web.http_port = runtime_config.port;
config.web.https_enabled = false;
prepare_android_runtime_dirs(data_dir, &config_store, &mut config).await?;
if let Some(device) = config.video.device.as_deref() {
if device == "auto" {
config.video.device = None;
}
}
config_store
.set(config.clone())
.await
.map_err(|err| format!("failed to persist Android runtime config: {err}"))?;
Ok((db, config_store, config))
}
async fn prepare_android_runtime_dirs(
data_dir: &Path,
config_store: &ConfigStore,
config: &mut AppConfig,
) -> Result<(), String> {
let mut updated = false;
if config.msd.msd_dir.trim().is_empty() {
config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string();
updated = true;
} else if !PathBuf::from(&config.msd.msd_dir).is_absolute() {
config.msd.msd_dir = data_dir
.join(&config.msd.msd_dir)
.to_string_lossy()
.to_string();
updated = true;
}
let msd_dir = config.msd.msd_dir_path();
tokio::fs::create_dir_all(msd_dir.join("images"))
.await
.map_err(|err| format!("failed to create Android MSD images dir: {err}"))?;
tokio::fs::create_dir_all(msd_dir.join("ventoy"))
.await
.map_err(|err| format!("failed to create Android MSD ventoy dir: {err}"))?;
if updated {
config_store
.set(config.clone())
.await
.map_err(|err| format!("failed to persist Android MSD dir: {err}"))?;
}
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn build_app_state(
data_dir: PathBuf,
db: DatabasePool,
config_store: ConfigStore,
config: AppConfig,
shutdown_tx: broadcast::Sender<ShutdownAction>,
) -> Result<Arc<AppState>, String> {
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
let user_store = UserStore::new(db.clone_pool());
let events = Arc::new(EventBus::new());
let (video_format, video_resolution) = parse_video_config(&config);
let streamer = Streamer::new();
streamer.set_event_bus(events.clone()).await;
if let Some(ref device_path) = config.video.device {
if let Err(err) = streamer
.apply_video_config(
device_path,
video_format,
video_resolution,
config.video.fps,
)
.await
{
tracing::warn!("Android video config failed, falling back to auto: {}", err);
}
}
let webrtc_streamer = WebRtcStreamer::with_config(WebRtcStreamerConfig {
resolution: video_resolution,
input_format: video_format,
fps: config.video.fps,
bitrate_preset: config.stream.bitrate_preset,
encoder_backend: encoder_type_to_backend(config.stream.encoder.clone()),
webrtc: build_webrtc_config(&config),
..Default::default()
});
let hid_backend = match config.hid.backend {
config::HidBackend::Otg => HidBackendType::Otg,
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
port: config.hid.ch9329_port.clone(),
baud_rate: config.hid.ch9329_baudrate,
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
},
config::HidBackend::None => HidBackendType::None,
};
let otg_service = Arc::new(OtgService::new());
if let Err(err) = otg_service.apply_config(&config.hid, &config.msd).await {
tracing::warn!("Failed to apply Android OTG config: {}", err);
}
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
hid.set_event_bus(events.clone()).await;
if let Err(err) = hid.init().await {
tracing::warn!("Failed to initialize Android HID backend: {}", err);
}
let msd = if config.msd.enabled {
let ventoy_resource_dir = data_dir.join("ventoy");
if ventoy_resource_dir.exists() {
if let Err(err) = ventoy_img::init_resources(&ventoy_resource_dir) {
tracing::warn!("Failed to initialize Android Ventoy resources: {}", err);
}
}
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
if let Err(err) = controller.init().await {
tracing::warn!("Failed to initialize Android MSD controller: {}", err);
None
} else {
controller.set_event_bus(events.clone()).await;
Some(controller)
}
} else {
None
};
let atx = if config.atx.enabled {
let controller = AtxController::new(config.atx.to_controller_config());
if let Err(err) = controller.init().await {
tracing::warn!("Failed to initialize Android ATX controller: {}", err);
None
} else {
Some(controller)
}
} else {
None
};
let audio = {
let audio_config = AudioControllerConfig {
enabled: config.audio.enabled,
device: config.audio.device.clone(),
quality: config
.audio
.quality
.parse::<AudioQuality>()
.unwrap_or(AudioQuality::Balanced),
};
let controller = AudioController::new(audio_config);
controller.set_event_bus(events.clone()).await;
if config.audio.enabled {
if let Err(err) = controller.start_streaming().await {
tracing::warn!("Failed to start Android audio: {}", err);
}
}
Arc::new(controller)
};
let extensions = Arc::new(ExtensionManager::new());
webrtc_streamer.set_hid_controller(hid.clone()).await;
webrtc_streamer.set_audio_controller(audio.clone()).await;
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
streamer.current_capture_config().await;
webrtc_streamer
.update_video_config(actual_resolution, actual_format, actual_fps)
.await;
if let Some(device_path) = device_path {
let (subdev_path, bridge_kind, v4l2_driver) = streamer
.current_device()
.await
.map(|device| {
(
device.subdev_path.clone(),
device.bridge_kind.clone(),
Some(device.driver.clone()),
)
})
.unwrap_or((None, None, None));
webrtc_streamer
.set_capture_device(
device_path,
jpeg_quality,
subdev_path,
bridge_kind,
v4l2_driver,
)
.await;
}
let stream_manager = VideoStreamManager::with_webrtc_streamer(
streamer.clone(),
webrtc_streamer.clone() as Arc<dyn crate::video::traits::VideoOutput>,
);
stream_manager.set_event_bus(events.clone()).await;
stream_manager.set_config_store(config_store.clone()).await;
{
let stream_manager_weak = Arc::downgrade(&stream_manager);
audio
.set_recovered_callback(Arc::new(move || {
if let Some(stream_manager) = stream_manager_weak.upgrade() {
tokio::spawn(async move {
stream_manager.reconnect_webrtc_audio_sources().await;
});
}
}))
.await;
}
if let Err(err) = stream_manager
.init_with_mode(config.stream.mode.clone())
.await
{
tracing::warn!("Failed to initialize Android stream manager: {}", err);
}
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Android third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
e
);
false
}
};
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
Some(Arc::new(RustDeskService::new(
config.rustdesk.clone(),
stream_manager.clone(),
hid.clone(),
audio.clone(),
)))
} else {
None
};
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
Some(Arc::new(RtspService::new(
config.rtsp.clone(),
stream_manager.clone(),
)))
} else {
None
};
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
Some(Arc::new(VncService::new(
config.vnc.clone(),
stream_manager.clone(),
hid.clone(),
)))
} else {
None
};
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
let state = AppState::new(
db,
config_store.clone(),
session_store,
user_store,
otg_service,
stream_manager,
webrtc_streamer,
hid,
computer_use,
msd,
atx,
audio,
rustdesk.clone(),
vnc.clone(),
rtsp.clone(),
extensions.clone(),
events.clone(),
update_service,
shutdown_tx,
data_dir,
);
extensions.set_event_bus(events.clone()).await;
if let Some(service) = rustdesk {
if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android RustDesk service: {}", err);
}
}
if let Some(service) = vnc {
if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android VNC service: {}", err);
}
}
if let Some(service) = rtsp {
if let Err(err) = service.start().await {
tracing::warn!("Failed to start Android RTSP service: {}", err);
}
}
let constraints = StreamCodecConstraints::from_config(&state.config.get());
if let Err(err) =
enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await
{
tracing::warn!("Failed to enforce Android stream constraints: {}", err);
}
state.publish_device_info().await;
spawn_device_info_broadcaster(state.clone(), events);
Ok(state)
}
fn build_webrtc_config(config: &AppConfig) -> WebRtcConfig {
let mut webrtc = WebRtcConfig::default();
if let Some(stun) = config
.stream
.stun_server
.as_ref()
.filter(|value| !value.is_empty())
{
webrtc.stun_servers.push(stun.clone());
}
if let Some(turn) = config
.stream
.turn_server
.as_ref()
.filter(|value| !value.is_empty())
{
webrtc
.turn_servers
.push(crate::webrtc::config::TurnServer::new(
turn.clone(),
config.stream.turn_username.clone().unwrap_or_default(),
config.stream.turn_password.clone().unwrap_or_default(),
));
}
webrtc
}
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
let format = config
.video
.format
.as_ref()
.and_then(|value| value.parse::<PixelFormat>().ok())
.unwrap_or(PixelFormat::Mjpeg);
(
format,
Resolution::new(config.video.width, config.video.height),
)
}
fn bind_android_listener(bind_address: &str, port: u16) -> Result<std::net::TcpListener, String> {
let ip = bind_address
.parse::<IpAddr>()
.map_err(|err| format!("invalid Android bind address {bind_address}: {err}"))?;
bind_tcp_listener(SocketAddr::new(ip, port))
.map_err(|err| format!("failed to bind Android listener {bind_address}:{port}: {err}"))
}
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
enum DeviceInfoTrigger {
Event,
Lagged { topic: &'static str, count: u64 },
}
const DEVICE_INFO_TOPICS: &[&str] = &[
"stream.state_changed",
"stream.config_applied",
"stream.mode_ready",
];
const DEBOUNCE_MS: u64 = 100;
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
for topic in DEVICE_INFO_TOPICS {
let Some(mut rx) = events.subscribe_topic(topic) else {
continue;
};
let trigger_tx = trigger_tx.clone();
let topic_name = *topic;
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(_) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: topic_name,
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
{
let mut dirty_rx = events.subscribe_device_info_dirty();
let trigger_tx = trigger_tx.clone();
tokio::spawn(async move {
loop {
match dirty_rx.recv().await {
Ok(()) => {
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
if trigger_tx
.send(DeviceInfoTrigger::Lagged {
topic: "device_info_dirty",
count,
})
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
tokio::spawn(async move {
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
let mut pending_broadcast = false;
loop {
let recv_result = if pending_broadcast {
let remaining =
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
} else {
Ok(trigger_rx.recv().await)
};
match recv_result {
Ok(Some(DeviceInfoTrigger::Event)) => pending_broadcast = true,
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
tracing::warn!(
"Android device info broadcaster lagged by {} events on {}",
count,
topic
);
pending_broadcast = true;
}
Ok(None) => break,
Err(_) => {}
}
if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
state.publish_device_info().await;
last_broadcast = Instant::now();
pending_broadcast = false;
}
}
});
}
async fn cleanup(state: &Arc<AppState>) {
state.extensions.stop_all().await;
if let Some(service) = state.rustdesk.read().await.as_ref() {
if let Err(err) = service.stop().await {
tracing::warn!("Failed to stop Android RustDesk service: {}", err);
}
}
if let Some(service) = state.vnc.read().await.as_ref() {
if let Err(err) = service.stop().await {
tracing::warn!("Failed to stop Android VNC service: {}", err);
}
}
if let Some(service) = state.rtsp.read().await.as_ref() {
if let Err(err) = service.stop().await {
tracing::warn!("Failed to stop Android RTSP service: {}", err);
}
}
if let Err(err) = state.stream_manager.stop().await {
tracing::warn!("Failed to stop Android stream manager: {}", err);
}
if let Err(err) = state.hid.shutdown().await {
tracing::warn!("Failed to stop Android HID: {}", err);
}
if let Some(msd) = state.msd.write().await.as_mut() {
if let Err(err) = msd.shutdown().await {
tracing::warn!("Failed to stop Android MSD: {}", err);
}
}
if let Err(err) = state.otg_service.shutdown().await {
tracing::warn!("Failed to stop Android OTG: {}", err);
}
if let Some(atx) = state.atx.write().await.as_mut() {
if let Err(err) = atx.shutdown().await {
tracing::warn!("Failed to stop Android ATX: {}", err);
}
}
if let Err(err) = state.audio.shutdown().await {
tracing::warn!("Failed to stop Android audio: {}", err);
}
}
fn init_logging() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let _ = tracing_log::LogTracer::init();
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "one_kvm=info,tower_http=info,webrtc_sctp=warn".into());
let fmt_layer = tracing_subscriber::fmt::layer();
if let Ok(path) = std::env::var("ONE_KVM_ANDROID_LOG_FILE") {
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(file) => {
let file_layer = tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(Arc::new(file));
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.with(file_layer)
.try_init();
}
Err(err) => {
eprintln!("failed to open Android Rust log file {path}: {err}");
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.try_init();
}
}
} else {
let _ = tracing_subscriber::registry()
.with(filter)
.with(fmt_layer)
.try_init();
}
});
}
fn ensure_rustls_provider() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let _ = CryptoProvider::install_default(ring::default_provider());
});
}

View File

@@ -1,4 +0,0 @@
//! Runtime entry points for packaged service modes.
#[cfg(feature = "android")]
pub mod android;

View File

@@ -175,9 +175,7 @@ impl MjpegStreamHandler {
} }
#[cfg(not(feature = "desktop"))] #[cfg(not(feature = "desktop"))]
{ {
warn!( warn!("Dropping non-JPEG frame because this build has no JPEG encoder");
"Dropping non-JPEG frame for MJPEG stream on Android; native encoder is not wired yet"
);
return; return;
} }
} else { } else {

View File

@@ -2,9 +2,9 @@
pub mod fs; pub mod fs;
pub mod host; pub mod host;
#[cfg(all(unix, not(target_os = "android")))] #[cfg(unix)]
pub mod net; pub mod net;
#[cfg(any(not(unix), target_os = "android"))] #[cfg(not(unix))]
#[path = "net_disabled.rs"] #[path = "net_disabled.rs"]
pub mod net; pub mod net;
pub mod serial; pub mod serial;

View File

@@ -1,122 +0,0 @@
//! Android FFmpeg/MediaCodec encoder glue.
use bytes::Bytes;
use hwcodec::common::{Quality, RateControl};
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
use crate::error::{AppError, Result};
use crate::video::format::{PixelFormat, Resolution};
pub struct AndroidMediaCodecH264Encoder {
inner: HwEncoder,
resolution: Resolution,
input_format: PixelFormat,
bitrate_kbps: u32,
}
impl AndroidMediaCodecH264Encoder {
pub fn new(
resolution: Resolution,
input_format: PixelFormat,
fps: u32,
bitrate_kbps: u32,
) -> Result<Self> {
let pixfmt = match input_format {
PixelFormat::Nv12 => resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
PixelFormat::Yuv420 => {
resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P)
}
other => {
return Err(AppError::VideoError(format!(
"FFmpeg h264_mediacodec accepts NV12/YUV420P memory frames; {other} requires conversion first"
)))
}
};
let ctx = EncodeContext {
name: "h264_mediacodec".to_string(),
mc_name: None,
width: resolution.width as i32,
height: resolution.height as i32,
pixfmt,
align: 1,
fps: fps.max(1) as i32,
gop: fps.max(1) as i32,
rc: RateControl::RC_CBR,
quality: Quality::Quality_Low,
kbs: bitrate_kbps.max(1) as i32,
q: 23,
thread_count: 1,
};
let inner = HwEncoder::new(ctx).map_err(|_| {
AppError::VideoError("Failed to create FFmpeg h264_mediacodec encoder".to_string())
})?;
Ok(Self {
inner,
resolution,
input_format,
bitrate_kbps: bitrate_kbps.max(1),
})
}
pub fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<AndroidH264Packet>> {
let min_len = self
.input_format
.frame_size(self.resolution)
.ok_or_else(|| AppError::VideoError("MediaCodec input must be raw YUV".to_string()))?;
if data.len() < min_len {
return Err(AppError::VideoError(format!(
"MediaCodec {} frame too small: {} < {}",
self.input_format,
data.len(),
min_len
)));
}
let packets = self
.inner
.encode_bytes(data, pts_ms)
.map_err(|err| AppError::VideoError(format!("h264_mediacodec encode failed: {err}")))?;
Ok(packets
.into_iter()
.map(|packet| AndroidH264Packet {
data: packet.data,
pts: packet.pts,
key_frame: packet.key == 1,
})
.collect())
}
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.inner
.set_bitrate(bitrate_kbps.max(1) as i32)
.map_err(|_| AppError::VideoError("Failed to set MediaCodec bitrate".to_string()))?;
self.bitrate_kbps = bitrate_kbps.max(1);
Ok(())
}
pub fn request_keyframe(&mut self) {
self.inner.request_keyframe();
}
pub fn codec_name(&self) -> &str {
"h264_mediacodec"
}
pub fn input_format(&self) -> PixelFormat {
self.input_format
}
}
unsafe impl Send for AndroidMediaCodecH264Encoder {}
#[derive(Debug, Clone)]
pub struct AndroidH264Packet {
pub data: Bytes,
pub pts: i64,
pub key_frame: bool,
}

View File

@@ -1,137 +0,0 @@
//! Android FFmpeg/MediaCodec MJPEG decoder glue.
use hwcodec::ffmpeg::AVPixelFormat;
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
use tracing::{info, warn};
use crate::error::{AppError, Result};
use crate::video::codec::convert::Nv12Converter;
use crate::video::format::{PixelFormat, Resolution};
pub struct AndroidMediaCodecMjpegDecoder {
decoder: Decoder,
resolution: Resolution,
nv12_converter: Option<Nv12Converter>,
last_output_format: Option<PixelFormat>,
pending_frames: u32,
}
impl AndroidMediaCodecMjpegDecoder {
pub fn new(resolution: Resolution) -> Result<Self> {
let ctx = DecodeContext {
name: "mjpeg_mediacodec".to_string(),
width: resolution.width as i32,
height: resolution.height as i32,
sw_pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
thread_count: 1,
};
let decoder = Decoder::new(ctx).map_err(|_| {
AppError::VideoError("Failed to create FFmpeg mjpeg_mediacodec decoder".to_string())
})?;
Ok(Self {
decoder,
resolution,
nv12_converter: None,
last_output_format: None,
pending_frames: 0,
})
}
pub fn decode_to_nv12(&mut self, mjpeg: &[u8]) -> Result<Vec<u8>> {
let frames = match self.decoder.decode(mjpeg) {
Ok(frames) => frames,
Err(err) if err == -11 => {
self.pending_frames += 1;
if self.pending_frames <= 3 {
return Err(AppError::VideoError(
"mjpeg_mediacodec decode needs more input".to_string(),
));
}
return Err(AppError::VideoError(
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
));
}
Err(err) => {
return Err(AppError::VideoError(format!(
"mjpeg_mediacodec decode failed: {err}"
)));
}
};
if frames.is_empty() {
self.pending_frames += 1;
if self.pending_frames <= 3 {
return Err(AppError::VideoError(
"mjpeg_mediacodec decode needs more input".to_string(),
));
}
return Err(AppError::VideoError(
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
));
}
self.pending_frames = 0;
if frames.len() > 1 {
warn!(
"mjpeg_mediacodec decode returned {} frames, using last",
frames.len()
);
}
let frame = frames.pop().ok_or_else(|| {
AppError::VideoError("mjpeg_mediacodec decode returned empty".to_string())
})?;
if frame.width as u32 != self.resolution.width
|| frame.height as u32 != self.resolution.height
{
warn!(
"mjpeg_mediacodec output size {}x{} differs from expected {}x{}",
frame.width, frame.height, self.resolution.width, self.resolution.height
);
}
let output_format = pixel_format_from_av(frame.pixfmt).ok_or_else(|| {
AppError::VideoError(format!(
"mjpeg_mediacodec output pixfmt {:?} is not supported",
frame.pixfmt
))
})?;
if self.last_output_format != Some(output_format) {
info!("mjpeg_mediacodec output format: {}", output_format);
self.last_output_format = Some(output_format);
}
match output_format {
PixelFormat::Nv12 => Ok(frame.data),
PixelFormat::Nv21 => {
let converter = self
.nv12_converter
.get_or_insert_with(|| Nv12Converter::nv21_to_nv12(self.resolution));
Ok(converter.convert(&frame.data)?.to_vec())
}
PixelFormat::Yuv420 => {
let converter = self
.nv12_converter
.get_or_insert_with(|| Nv12Converter::yuv420_to_nv12(self.resolution));
Ok(converter.convert(&frame.data)?.to_vec())
}
other => Err(AppError::VideoError(format!(
"mjpeg_mediacodec output {} cannot be converted to NV12",
other
))),
}
}
}
fn pixel_format_from_av(format: AVPixelFormat) -> Option<PixelFormat> {
match format {
AVPixelFormat::AV_PIX_FMT_NV12 => Some(PixelFormat::Nv12),
AVPixelFormat::AV_PIX_FMT_NV21 => Some(PixelFormat::Nv21),
AVPixelFormat::AV_PIX_FMT_YUV420P | AVPixelFormat::AV_PIX_FMT_YUVJ420P => {
Some(PixelFormat::Yuv420)
}
_ => None,
}
}
unsafe impl Send for AndroidMediaCodecMjpegDecoder {}

View File

@@ -48,8 +48,6 @@ pub enum H264EncoderType {
Rkmpp, Rkmpp,
/// V4L2 M2M (ARM generic) - requires hwcodec extension /// V4L2 M2M (ARM generic) - requires hwcodec extension
V4l2M2m, V4l2M2m,
/// Android MediaCodec via FFmpeg
MediaCodec,
/// Software encoding (libx264/openh264) /// Software encoding (libx264/openh264)
Software, Software,
/// No encoder available /// No encoder available
@@ -66,7 +64,6 @@ impl std::fmt::Display for H264EncoderType {
H264EncoderType::Vaapi => write!(f, "VAAPI"), H264EncoderType::Vaapi => write!(f, "VAAPI"),
H264EncoderType::Rkmpp => write!(f, "RKMPP"), H264EncoderType::Rkmpp => write!(f, "RKMPP"),
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
H264EncoderType::MediaCodec => write!(f, "MediaCodec"),
H264EncoderType::Software => write!(f, "Software"), H264EncoderType::Software => write!(f, "Software"),
H264EncoderType::None => write!(f, "None"), H264EncoderType::None => write!(f, "None"),
} }
@@ -83,7 +80,6 @@ impl From<EncoderBackend> for H264EncoderType {
EncoderBackend::Vaapi => H264EncoderType::Vaapi, EncoderBackend::Vaapi => H264EncoderType::Vaapi,
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp, EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m, EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
EncoderBackend::MediaCodec => H264EncoderType::MediaCodec,
EncoderBackend::Software => H264EncoderType::Software, EncoderBackend::Software => H264EncoderType::Software,
} }
} }
@@ -196,7 +192,6 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
let ctx = EncodeContext { let ctx = EncodeContext {
name: String::new(), name: String::new(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt: resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P), pixfmt: resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
@@ -296,7 +291,6 @@ impl H264Encoder {
let ctx = EncodeContext { let ctx = EncodeContext {
name: codec_name.to_string(), name: codec_name.to_string(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt, pixfmt,

View File

@@ -45,8 +45,6 @@ pub enum H265EncoderType {
Rkmpp, Rkmpp,
/// V4L2 M2M (ARM generic) /// V4L2 M2M (ARM generic)
V4l2M2m, V4l2M2m,
/// Android MediaCodec via FFmpeg
MediaCodec,
/// Software encoder (libx265) /// Software encoder (libx265)
Software, Software,
/// No encoder available /// No encoder available
@@ -63,7 +61,6 @@ impl std::fmt::Display for H265EncoderType {
H265EncoderType::Vaapi => write!(f, "VAAPI"), H265EncoderType::Vaapi => write!(f, "VAAPI"),
H265EncoderType::Rkmpp => write!(f, "RKMPP"), H265EncoderType::Rkmpp => write!(f, "RKMPP"),
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
H265EncoderType::MediaCodec => write!(f, "MediaCodec"),
H265EncoderType::Software => write!(f, "Software"), H265EncoderType::Software => write!(f, "Software"),
H265EncoderType::None => write!(f, "None"), H265EncoderType::None => write!(f, "None"),
} }
@@ -79,7 +76,6 @@ impl From<EncoderBackend> for H265EncoderType {
EncoderBackend::Vaapi => H265EncoderType::Vaapi, EncoderBackend::Vaapi => H265EncoderType::Vaapi,
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp, EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m, EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
EncoderBackend::MediaCodec => H265EncoderType::MediaCodec,
EncoderBackend::Software => H265EncoderType::Software, EncoderBackend::Software => H265EncoderType::Software,
} }
} }
@@ -199,7 +195,6 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
let ctx = EncodeContext { let ctx = EncodeContext {
name: String::new(), name: String::new(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12), pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
@@ -373,7 +368,6 @@ impl H265Encoder {
let ctx = EncodeContext { let ctx = EncodeContext {
name: codec_name.to_string(), name: codec_name.to_string(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt, pixfmt,

View File

@@ -3,10 +3,6 @@
use hwcodec::common::DataFormat; use hwcodec::common::DataFormat;
use hwcodec::ffmpeg_ram::CodecInfo; use hwcodec::ffmpeg_ram::CodecInfo;
#[cfg(feature = "android-mediacodec")]
pub mod android_mediacodec;
#[cfg(feature = "android-mediacodec")]
pub mod android_mjpeg;
pub mod convert; pub mod convert;
pub mod h264; pub mod h264;
@@ -23,10 +19,6 @@ pub mod vp9;
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))] #[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
pub mod mjpeg_rkmpp; pub mod mjpeg_rkmpp;
#[cfg(feature = "android-mediacodec")]
pub use android_mediacodec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
#[cfg(feature = "android-mediacodec")]
pub use android_mjpeg::AndroidMediaCodecMjpegDecoder;
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer}; pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat}; pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};

View File

@@ -96,8 +96,6 @@ pub enum EncoderBackend {
Rkmpp, Rkmpp,
/// V4L2 Memory-to-Memory (ARM) /// V4L2 Memory-to-Memory (ARM)
V4l2m2m, V4l2m2m,
/// Android MediaCodec via FFmpeg
MediaCodec,
/// Software encoding (libx264, libx265, libvpx) /// Software encoding (libx264, libx265, libvpx)
Software, Software,
} }
@@ -117,8 +115,6 @@ impl EncoderBackend {
EncoderBackend::Rkmpp EncoderBackend::Rkmpp
} else if name.contains("v4l2m2m") { } else if name.contains("v4l2m2m") {
EncoderBackend::V4l2m2m EncoderBackend::V4l2m2m
} else if name.contains("mediacodec") {
EncoderBackend::MediaCodec
} else { } else {
EncoderBackend::Software EncoderBackend::Software
} }
@@ -138,7 +134,6 @@ impl EncoderBackend {
EncoderBackend::Amf => "AMF", EncoderBackend::Amf => "AMF",
EncoderBackend::Rkmpp => "RKMPP", EncoderBackend::Rkmpp => "RKMPP",
EncoderBackend::V4l2m2m => "V4L2 M2M", EncoderBackend::V4l2m2m => "V4L2 M2M",
EncoderBackend::MediaCodec => "MediaCodec",
EncoderBackend::Software => "Software", EncoderBackend::Software => "Software",
} }
} }
@@ -153,7 +148,6 @@ impl EncoderBackend {
"amf" => Some(EncoderBackend::Amf), "amf" => Some(EncoderBackend::Amf),
"rkmpp" => Some(EncoderBackend::Rkmpp), "rkmpp" => Some(EncoderBackend::Rkmpp),
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m), "v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
"mediacodec" | "android-mediacodec" => Some(EncoderBackend::MediaCodec),
"software" | "cpu" => Some(EncoderBackend::Software), "software" | "cpu" => Some(EncoderBackend::Software),
_ => None, _ => None,
} }
@@ -261,8 +255,8 @@ impl EncoderRegistry {
let codec_name = match format { let codec_name = match format {
VideoEncoderType::H264 => "libx264", VideoEncoderType::H264 => "libx264",
VideoEncoderType::H265 => "libx265", VideoEncoderType::H265 => "libx265",
VideoEncoderType::VP8 => "libvpx", VideoEncoderType::VP8 => "libvpx_vp8",
VideoEncoderType::VP9 => "libvpx-vp9", VideoEncoderType::VP9 => "libvpx_vp9",
}; };
encoders.push(AvailableEncoder { encoders.push(AvailableEncoder {
@@ -309,10 +303,9 @@ impl EncoderRegistry {
self.encoders.clear(); self.encoders.clear();
self.detection_resolution = (width, height); self.detection_resolution = (width, height);
// Create test context for encoder detection // Create test context for encoder detection.
let ctx = EncodeContext { let ctx = EncodeContext {
name: String::new(), name: String::new(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12), pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
@@ -332,7 +325,6 @@ impl EncoderRegistry {
ctx.clone(), ctx.clone(),
Duration::from_millis(DETECT_TIMEOUT_MS), Duration::from_millis(DETECT_TIMEOUT_MS),
); );
info!("Found {} encoders from hwcodec", all_encoders.len()); info!("Found {} encoders from hwcodec", all_encoders.len());
for codec_info in &all_encoders { for codec_info in &all_encoders {

View File

@@ -2,8 +2,6 @@ use serde::Serialize;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[cfg(feature = "android-mediacodec")]
use super::AndroidMediaCodecH264Encoder;
use super::{ use super::{
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder, EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
VP9Config, VP9Encoder, VideoEncoderType, VP9Config, VP9Encoder, VideoEncoderType,
@@ -237,32 +235,6 @@ fn run_smoke_test(
} }
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> { fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
#[cfg(feature = "android-mediacodec")]
if codec_name_ffmpeg == "h264_mediacodec" {
let mut encoder = AndroidMediaCodecH264Encoder::new(
resolution,
PixelFormat::Nv12,
30,
bitrate_kbps_for_resolution(resolution),
)?;
encoder.request_keyframe();
let frame = build_nv12_test_frame(
resolution,
PixelFormat::Nv12.frame_size(resolution).unwrap_or(0),
);
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
if frames.iter().any(|frame| !frame.data.is_empty()) {
return Ok(());
}
}
return Err(AppError::VideoError(
"Encoder produced no output after multiple frames".to_string(),
));
}
let mut encoder = H264Encoder::with_codec( let mut encoder = H264Encoder::with_codec(
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)), H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
codec_name_ffmpeg, codec_name_ffmpeg,

View File

@@ -130,7 +130,6 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
let ctx = EncodeContext { let ctx = EncodeContext {
name: String::new(), name: String::new(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12), pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
@@ -271,7 +270,6 @@ impl VP8Encoder {
let ctx = EncodeContext { let ctx = EncodeContext {
name: codec_name.to_string(), name: codec_name.to_string(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt, pixfmt,

View File

@@ -130,7 +130,6 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
let ctx = EncodeContext { let ctx = EncodeContext {
name: String::new(), name: String::new(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12), pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
@@ -271,7 +270,6 @@ impl VP9Encoder {
let ctx = EncodeContext { let ctx = EncodeContext {
name: codec_name.to_string(), name: codec_name.to_string(),
mc_name: None,
width: width as i32, width: width as i32,
height: height as i32, height: height as i32,
pixfmt, pixfmt,

View File

@@ -963,19 +963,6 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
// for a single MIPI CSI pipeline. Keep only the highest-priority node per // for a single MIPI CSI pipeline. Keep only the highest-priority node per
// (driver, bus_info) group so users see one device instead of ~11. // (driver, bus_info) group so users see one device instead of ~11.
dedup_platform_subdevices(&mut devices); dedup_platform_subdevices(&mut devices);
devices.retain(|device| {
let hide = should_hide_android_platform_node(device);
if hide {
debug!(
"Hiding Android platform video node: {} ({}) {}",
device.name,
device.driver,
device.path.display()
);
}
!hide
});
info!("Found {} video capture devices", devices.len()); info!("Found {} video capture devices", devices.len());
Ok(devices) Ok(devices)
} }
@@ -1055,33 +1042,6 @@ fn dedup_platform_subdevices(devices: &mut Vec<VideoDeviceInfo>) {
}); });
} }
fn should_hide_android_platform_node(device: &VideoDeviceInfo) -> bool {
if !cfg!(feature = "android") {
return false;
}
let driver = device.driver.to_ascii_lowercase();
let name = device.name.to_ascii_lowercase();
let card = device.card.to_ascii_lowercase();
let usb_device = driver == "uvcvideo" || device.bus_info.starts_with("usb-");
let known_bridge =
driver.contains("rkcif") || driver.contains("rk_hdmirx") || driver.contains("tc358743");
if usb_device || known_bridge {
return false;
}
matches!(
driver.as_str(),
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
) || matches!(
name.as_str(),
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
) || matches!(
card.as_str(),
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
)
}
/// rkcif registers many `/dev/video*` queues; probing all in parallel can /// rkcif registers many `/dev/video*` queues; probing all in parallel can
/// contend and time out. Keep one node per board (lowest `videoN`). /// contend and time out. Keep one node per board (lowest `videoN`).
fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) { fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) {
@@ -1185,20 +1145,6 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
.to_lowercase(); .to_lowercase();
let driver = extract_uevent_value(&uevent, "driver"); let driver = extract_uevent_value(&uevent, "driver");
if cfg!(feature = "android") {
let platform_skip = ["ionvideo", "amlvideo", "amlvideo2", "videosync"];
let driver_skip = driver
.as_ref()
.is_some_and(|driver| platform_skip.iter().any(|hint| driver == hint));
if driver_skip || platform_skip.iter().any(|hint| sysfs_name == *hint) {
debug!(
"Skipping Android platform video node {:?}: {}",
path, sysfs_name
);
return false;
}
}
let mut maybe_capture = false; let mut maybe_capture = false;
let capture_hints = [ let capture_hints = [
"capture", "capture",

View File

@@ -8,21 +8,19 @@ pub mod codec_constraints;
pub mod device; pub mod device;
pub mod format; pub mod format;
pub mod frame; pub mod frame;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod pipeline; pub mod pipeline;
pub mod signal; pub mod signal;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod stream_manager; pub mod stream_manager;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod streamer; pub mod streamer;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod traits; pub mod traits;
#[cfg(any(feature = "android", feature = "desktop"))] #[cfg(feature = "desktop")]
pub mod types; pub mod types;
pub use capture::{CaptureMeta, CaptureStream}; pub use capture::{CaptureMeta, CaptureStream};
#[cfg(feature = "android-mediacodec")]
pub use codec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer}; pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer};
pub use device::{VideoDevice, VideoDeviceInfo}; pub use device::{VideoDevice, VideoDeviceInfo};
pub use format::PixelFormat; pub use format::PixelFormat;

View File

@@ -6,16 +6,9 @@ use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncode
use crate::video::codec::traits::EncoderConfig; use crate::video::codec::traits::EncoderConfig;
use crate::video::codec::vp8::{VP8Config, VP8Encoder}; use crate::video::codec::vp8::{VP8Config, VP8Encoder};
use crate::video::codec::vp9::{VP9Config, VP9Encoder}; use crate::video::codec::vp9::{VP9Config, VP9Encoder};
#[cfg(feature = "android-mediacodec")]
use crate::video::codec::AndroidMediaCodecH264Encoder;
#[cfg(feature = "android-mediacodec")]
use crate::video::codec::AndroidMediaCodecMjpegDecoder;
use crate::video::format::{PixelFormat, Resolution}; use crate::video::format::{PixelFormat, Resolution};
use bytes::Bytes; use bytes::Bytes;
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
use hwcodec::ffmpeg_hw::{ use hwcodec::ffmpeg_hw::{
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline, last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
}; };
@@ -29,15 +22,9 @@ pub(super) struct EncoderThreadState {
pub(super) nv12_converter: Option<Nv12Converter>, pub(super) nv12_converter: Option<Nv12Converter>,
pub(super) yuv420p_converter: Option<PixelConverter>, pub(super) yuv420p_converter: Option<PixelConverter>,
pub(super) encoder_needs_yuv420p: bool, pub(super) encoder_needs_yuv420p: bool,
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>, pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
pub(super) ffmpeg_hw_enabled: bool, pub(super) ffmpeg_hw_enabled: bool,
pub(super) fps: u32, pub(super) fps: u32,
pub(super) codec: VideoEncoderType, pub(super) codec: VideoEncoderType,
@@ -129,35 +116,6 @@ impl VideoEncoderTrait for H265EncoderWrapper {
} }
} }
#[cfg(feature = "android-mediacodec")]
struct AndroidMediaCodecH264EncoderWrapper(AndroidMediaCodecH264Encoder);
#[cfg(feature = "android-mediacodec")]
impl VideoEncoderTrait for AndroidMediaCodecH264EncoderWrapper {
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
let frames = self.0.encode_raw(data, pts_ms)?;
Ok(frames
.into_iter()
.map(|f| EncodedFrame {
data: f.data,
key: if f.key_frame { 1 } else { 0 },
})
.collect())
}
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
self.0.set_bitrate(bitrate_kbps)
}
fn codec_name(&self) -> &str {
self.0.codec_name()
}
fn request_keyframe(&mut self) {
self.0.request_keyframe()
}
}
struct VP8EncoderWrapper(VP8Encoder); struct VP8EncoderWrapper(VP8Encoder);
impl VideoEncoderTrait for VP8EncoderWrapper { impl VideoEncoderTrait for VP8EncoderWrapper {
@@ -209,50 +167,12 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
} }
pub(super) enum MjpegDecoderKind { pub(super) enum MjpegDecoderKind {
#[cfg(feature = "android-mediacodec")] Libyuv { decoder: MjpegToNv12Decoder },
AndroidMediaCodec {
decoder: AndroidMediaCodecMjpegDecoder,
fallback: Box<MjpegDecoderKind>,
fallback_active: bool,
output: Vec<u8>,
},
Libyuv {
decoder: MjpegToNv12Decoder,
},
} }
impl MjpegDecoderKind { impl MjpegDecoderKind {
pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> { pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> {
match self { match self {
#[cfg(feature = "android-mediacodec")]
MjpegDecoderKind::AndroidMediaCodec {
decoder,
fallback,
fallback_active,
output,
} => {
if !*fallback_active {
match decoder.decode_to_nv12(data) {
Ok(decoded) => {
*output = decoded;
return Ok(output.as_slice());
}
Err(AppError::VideoError(message))
if message.contains("needs more input") =>
{
return Err(AppError::VideoError(message));
}
Err(err) => {
tracing::warn!(
"Android MediaCodec MJPEG decode failed; falling back to libyuv MJPEG->NV12: {}",
err
);
*fallback_active = true;
}
}
}
fallback.decode(data)
}
MjpegDecoderKind::Libyuv { decoder } => decoder.decode(data), MjpegDecoderKind::Libyuv { decoder } => decoder.decode(data),
} }
} }
@@ -265,40 +185,6 @@ fn libyuv_mjpeg_decoder(resolution: Resolution) -> MjpegDecoderKind {
} }
fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, PixelFormat)> { fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, PixelFormat)> {
#[cfg(feature = "android-mediacodec")]
{
if std::env::var_os("ONE_KVM_ANDROID_MJPEG_MEDIACODEC").is_none() {
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
return Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12));
}
info!("MJPEG input detected, trying Android MediaCodec decoder (MJPEG -> NV12)");
match AndroidMediaCodecMjpegDecoder::new(resolution) {
Ok(decoder) => {
info!("Using Android MediaCodec MJPEG decoder");
return Ok((
MjpegDecoderKind::AndroidMediaCodec {
decoder,
fallback: Box::new(libyuv_mjpeg_decoder(resolution)),
fallback_active: false,
output: Vec::with_capacity(
PixelFormat::Nv12
.frame_size(resolution)
.unwrap_or((resolution.width * resolution.height * 3 / 2) as usize),
),
},
PixelFormat::Nv12,
));
}
Err(err) => {
tracing::warn!(
"Android MediaCodec MJPEG decoder unavailable; using libyuv MJPEG->NV12: {}",
err
);
}
}
}
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)"); info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12)) Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
} }
@@ -400,15 +286,9 @@ pub(super) fn build_encoder_state(
} }
}; };
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp"); let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
if needs_mjpeg_decode if needs_mjpeg_decode
&& is_rkmpp_encoder && is_rkmpp_encoder
&& matches!( && matches!(
@@ -448,15 +328,9 @@ pub(super) fn build_encoder_state(
nv12_converter: None, nv12_converter: None,
yuv420p_converter: None, yuv420p_converter: None,
encoder_needs_yuv420p: false, encoder_needs_yuv420p: false,
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
ffmpeg_hw_pipeline: Some(pipeline), ffmpeg_hw_pipeline: Some(pipeline),
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
ffmpeg_hw_enabled: true, ffmpeg_hw_enabled: true,
fps: config.fps, fps: config.fps,
codec: config.output_codec, codec: config.output_codec,
@@ -495,40 +369,7 @@ pub(super) fn build_encoder_state(
); );
} }
#[cfg(feature = "android-mediacodec")] create_h264_encoder(config, input_format, &codec_name)?
{
if codec_name == "h264_mediacodec" {
info!(
"Creating Android MediaCodec H264 encoder for {:?} input",
input_format
);
let pixel_format = match input_format {
H264InputFormat::Nv12 => PixelFormat::Nv12,
H264InputFormat::Yuv420p => PixelFormat::Yuv420,
other => {
return Err(AppError::VideoError(format!(
"Android MediaCodec H264 does not support {:?} direct input",
other
)));
}
};
let encoder = AndroidMediaCodecH264Encoder::new(
config.resolution,
pixel_format,
config.fps,
config.bitrate_kbps(),
)?;
info!("Created Android MediaCodec H264 encoder");
Box::new(AndroidMediaCodecH264EncoderWrapper(encoder))
} else {
create_h264_encoder(config, input_format, &codec_name)?
}
}
#[cfg(not(feature = "android-mediacodec"))]
{
create_h264_encoder(config, input_format, &codec_name)?
}
} }
VideoEncoderType::H265 => { VideoEncoderType::H265 => {
let codec_name = selected_codec_name.clone(); let codec_name = selected_codec_name.clone();
@@ -622,11 +463,6 @@ pub(super) fn build_encoder_state(
pipeline_input_format, pipeline_input_format,
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
) )
} else if codec_name.contains("mediacodec") {
matches!(
pipeline_input_format,
PixelFormat::Nv12 | PixelFormat::Yuv420
)
} else { } else {
false false
}; };
@@ -676,15 +512,9 @@ pub(super) fn build_encoder_state(
nv12_converter, nv12_converter,
yuv420p_converter, yuv420p_converter,
encoder_needs_yuv420p: needs_yuv420p, encoder_needs_yuv420p: needs_yuv420p,
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
ffmpeg_hw_pipeline: None, ffmpeg_hw_pipeline: None,
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
ffmpeg_hw_enabled: false, ffmpeg_hw_enabled: false,
fps: config.fps, fps: config.fps,
codec: config.output_codec, codec: config.output_codec,
@@ -708,12 +538,6 @@ fn h264_direct_input_format(
PixelFormat::Nv24 => Some(H264InputFormat::Nv24), PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
_ => None, _ => None,
} }
} else if codec_name.contains("mediacodec") {
match input_format {
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
_ => None,
}
} else if codec_name.contains("libx264") { } else if codec_name.contains("libx264") {
match input_format { match input_format {
PixelFormat::Nv12 => Some(H264InputFormat::Nv12), PixelFormat::Nv12 => Some(H264InputFormat::Nv12),

View File

@@ -61,10 +61,7 @@ use crate::video::signal::SignalStatus;
const MIN_CAPTURE_FRAME_SIZE: usize = 128; const MIN_CAPTURE_FRAME_SIZE: usize = 128;
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error; use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
/// Encoded video frame for distribution /// Encoded video frame for distribution
@@ -484,15 +481,9 @@ impl SharedVideoPipeline {
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> { fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
match cmd { match cmd {
PipelineCmd::SetBitrate { bitrate_kbps, gop } => { PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
#[cfg(any( #[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
not(any(target_arch = "aarch64", target_arch = "arm")),
target_os = "android"
))]
let _ = gop; let _ = gop;
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
if state.ffmpeg_hw_enabled { if state.ffmpeg_hw_enabled {
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline { if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
pipeline pipeline
@@ -659,7 +650,7 @@ impl SharedVideoPipeline {
*guard = Some(cmd_tx); *guard = Some(cmd_tx);
} }
// Encoder loop uses a dedicated OS thread because FFmpeg/MediaCodec work is synchronous. // Encoder loop uses a dedicated OS thread because FFmpeg work is synchronous.
{ {
let pipeline = pipeline.clone(); let pipeline = pipeline.clone();
let latest_frame = latest_frame.clone(); let latest_frame = latest_frame.clone();
@@ -1289,10 +1280,7 @@ impl SharedVideoPipeline {
current_ts_us.saturating_sub(start_ts_us) / 1000 current_ts_us.saturating_sub(start_ts_us) / 1000
}; };
#[cfg(all( #[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
any(target_arch = "aarch64", target_arch = "arm"),
not(target_os = "android")
))]
if state.ffmpeg_hw_enabled { if state.ffmpeg_hw_enabled {
if input_format != PixelFormat::Mjpeg { if input_format != PixelFormat::Mjpeg {
return Err(AppError::VideoError( return Err(AppError::VideoError(

View File

@@ -9,12 +9,6 @@ pub async fn update_overview(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
axum::extract::Query(query): axum::extract::Query<UpdateOverviewQuery>, axum::extract::Query(query): axum::extract::Query<UpdateOverviewQuery>,
) -> Result<Json<UpdateOverviewResponse>> { ) -> Result<Json<UpdateOverviewResponse>> {
if cfg!(feature = "android") {
return Err(AppError::BadRequest(
"Online upgrade is disabled on Android".to_string(),
));
}
let channel = query.channel.unwrap_or(UpdateChannel::Stable); let channel = query.channel.unwrap_or(UpdateChannel::Stable);
let response = state.update.overview(channel).await?; let response = state.update.overview(channel).await?;
Ok(Json(response)) Ok(Json(response))
@@ -24,12 +18,6 @@ pub async fn update_upgrade(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<UpgradeRequest>, Json(req): Json<UpgradeRequest>,
) -> Result<Json<LoginResponse>> { ) -> Result<Json<LoginResponse>> {
if cfg!(feature = "android") {
return Err(AppError::BadRequest(
"Online upgrade is disabled on Android".to_string(),
));
}
state.update.start_upgrade(req, state.shutdown_tx.clone())?; state.update.start_upgrade(req, state.shutdown_tx.clone())?;
Ok(Json(LoginResponse { Ok(Json(LoginResponse {
@@ -41,11 +29,5 @@ pub async fn update_upgrade(
pub async fn update_status( pub async fn update_status(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<UpdateStatusResponse>> { ) -> Result<Json<UpdateStatusResponse>> {
if cfg!(feature = "android") {
return Err(AppError::BadRequest(
"Online upgrade is disabled on Android".to_string(),
));
}
Ok(Json(state.update.status().await)) Ok(Json(state.update.status().await))
} }

View File

@@ -66,7 +66,7 @@ export interface FeatureCapability {
} }
export interface PlatformCapabilities { export interface PlatformCapabilities {
mode: 'android_amlogic' | 'linux' | 'windows' mode: 'linux' | 'windows'
mode_label: string mode_label: string
video_capture: FeatureCapability video_capture: FeatureCapability
encoder: FeatureCapability encoder: FeatureCapability

View File

@@ -125,7 +125,6 @@ const authStore = useAuthStore()
const featureVisibility = useFeatureVisibility() const featureVisibility = useFeatureVisibility()
const isWindows = computed(() => systemStore.platform?.mode === 'windows') const isWindows = computed(() => systemStore.platform?.mode === 'windows')
const isAndroid = computed(() => systemStore.platform?.mode === 'android_amlogic')
const msdAvailable = computed(() => systemStore.platform?.msd.available ?? systemStore.capabilities?.msd.available ?? false) const msdAvailable = computed(() => systemStore.platform?.msd.available ?? systemStore.capabilities?.msd.available ?? false)
const activeSection = ref<SettingsSectionId>('appearance') const activeSection = ref<SettingsSectionId>('appearance')
@@ -285,7 +284,6 @@ async function loadSectionData(section: SettingsSectionId) {
]) ])
return return
case 'about': case 'about':
if (isAndroid.value) return
await Promise.all([ await Promise.all([
loadUpdateOverview(), loadUpdateOverview(),
refreshUpdateStatus(), refreshUpdateStatus(),
@@ -2181,7 +2179,6 @@ async function triggerAutoRestart() {
} }
async function loadUpdateOverview() { async function loadUpdateOverview() {
if (isAndroid.value) return
updateLoading.value = true updateLoading.value = true
try { try {
updateOverview.value = await updateApi.overview(updateChannel.value) updateOverview.value = await updateApi.overview(updateChannel.value)
@@ -2197,7 +2194,6 @@ async function loadUpdateOverview() {
} }
async function refreshUpdateStatus() { async function refreshUpdateStatus() {
if (isAndroid.value) return
try { try {
updateStatus.value = await updateApi.status() updateStatus.value = await updateApi.status()
@@ -2230,7 +2226,6 @@ function stopUpdatePolling() {
} }
function startUpdatePolling() { function startUpdatePolling() {
if (isAndroid.value) return
if (updateStatusTimer !== null) return if (updateStatusTimer !== null) return
updateStatusTimer = window.setInterval(async () => { updateStatusTimer = window.setInterval(async () => {
await refreshUpdateStatus() await refreshUpdateStatus()
@@ -2245,7 +2240,6 @@ function startUpdatePolling() {
} }
async function startOnlineUpgrade() { async function startOnlineUpgrade() {
if (isAndroid.value) return
try { try {
updateSawRestarting.value = false updateSawRestarting.value = false
updateSawRequestFailure.value = false updateSawRequestFailure.value = false
@@ -2642,7 +2636,7 @@ onMounted(async () => {
}) })
watch(updateChannel, async () => { watch(updateChannel, async () => {
if (activeSection.value === 'about' && !isAndroid.value) { if (activeSection.value === 'about') {
await loadUpdateOverview() await loadUpdateOverview()
} }
}) })
@@ -5198,7 +5192,7 @@ watch(isWindows, () => {
<!-- About Section --> <!-- About Section -->
<div v-show="activeSection === 'about'" class="space-y-6"> <div v-show="activeSection === 'about'" class="space-y-6">
<Card v-if="!isAndroid"> <Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0"> <CardHeader class="flex flex-row items-start justify-between space-y-0">
<div class="space-y-1.5"> <div class="space-y-1.5">
<CardTitle>{{ t('settings.onlineUpgrade') }}</CardTitle> <CardTitle>{{ t('settings.onlineUpgrade') }}</CardTitle>