diff --git a/.gitignore b/.gitignore index ffbbda9c..00b24a54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Rust -/target/ +target/ Cargo.lock # IDE @@ -31,6 +31,7 @@ Thumbs.db # Build artifacts /dist/ /build-staging +/.tmp/ # Frontend (built files) /web/node_modules/ diff --git a/Cargo.toml b/Cargo.toml index 935a24e4..b53f3980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,119 +9,245 @@ repository = "https://github.com/mofeng-git/One-KVM" keywords = ["kvm", "ipkvm", "remote-management", "embedded"] categories = ["embedded", "network-programming"] +[features] +default = ["desktop"] +desktop = [ + "dep:tokio", + "dep:tokio-util", + "dep:axum", + "dep:axum-extra", + "dep:tower-http", + "dep:sqlx", + "dep:serde", + "dep:serde_json", + "dep:tracing", + "dep:tracing-subscriber", + "dep:thiserror", + "dep:anyhow", + "dep:argon2", + "dep:rand", + "dep:uuid", + "dep:base64", + "dep:nix", + "dep:reqwest", + "dep:urlencoding", + "dep:rust-embed", + "dep:mime_guess", + "dep:rustls", + "dep:rcgen", + "dep:axum-server", + "dep:clap", + "dep:time", + "dep:bytes", + "dep:bytemuck", + "dep:xxhash-rust", + "dep:async-stream", + "dep:futures", + "dep:tokio-tungstenite", + "dep:parking_lot", + "dep:arc-swap", + "dep:webrtc", + "dep:rtp", + "dep:rtsp-types", + "dep:sdp-types", + "dep:serialport", + "dep:async-trait", + "dep:libc", + "dep:ventoy-img", + "dep:protobuf", + "dep:sodiumoxide", + "dep:sha2", + "dep:typeshare", + "dep:hwcodec", + "dep:libyuv", + "dep:turbojpeg", + "dep:audiopus", + "dep:v4l2r", + "dep:alsa", + "dep:gpio-cdev", + "dep:cpal", + "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:serialport", + "dep:sha2", + "dep:sodiumoxide", + "dep:sqlx", + "dep:alsa", + "dep:audiopus", + "dep:thiserror", + "dep:time", + "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] # Async runtime -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } +tokio = { version = "1", features = ["full"], optional = true } +tokio-util = { version = "0.7", features = ["rt"], optional = true } # Web framework -axum = { version = "0.8", features = ["ws", "multipart", "tokio"] } -axum-extra = { version = "0.12", features = ["cookie"] } -tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] } +axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true } +axum-extra = { version = "0.12", features = ["cookie"], optional = true } +tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true } # Database - Use bundled SQLite for static linking -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"], optional = true } # Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } # Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] } +tracing = { version = "0.1", optional = true } +tracing-log = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"], optional = true } # Error handling -thiserror = "2" -anyhow = "1" +thiserror = { version = "2", optional = true } +anyhow = { version = "1", optional = true } # Authentication -argon2 = "0.5" -rand = "0.9" +argon2 = { version = "0.5", optional = true } +rand = { version = "0.9", optional = true } # Utilities -uuid = { version = "1", features = ["v4", "serde"] } -base64 = "0.22" -nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] } +uuid = { version = "1", features = ["v4", "serde"], optional = true } +base64 = { version = "0.22", optional = true } # HTTP client (for URL downloads) # Use rustls by default, but allow native-tls for systems with older GLIBC -reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false } -urlencoding = "2" +reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false, optional = true } +urlencoding = { version = "2", optional = true } # Static file embedding -rust-embed = { version = "8", features = ["compression", "debug-embed"] } -mime_guess = "2" +rust-embed = { version = "8", features = ["compression", "debug-embed"], optional = true } +mime_guess = { version = "2", optional = true } # TLS/HTTPS -rustls = { version = "0.23", features = ["ring"] } -rcgen = "0.14" -axum-server = { version = "0.8", features = ["tls-rustls"] } +rustls = { version = "0.23", features = ["ring"], optional = true } +rcgen = { version = "0.14", optional = true } +axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } # CLI argument parsing -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive"], optional = true } # Time (cookie max_age + RFC3339 timestamps) -time = { version = "0.3", features = ["serde", "formatting", "parsing"] } +time = { version = "0.3", features = ["serde", "formatting", "parsing"], optional = true } # Bytes handling -bytes = "1" -bytemuck = { version = "1.24", features = ["derive"] } +bytes = { version = "1", optional = true } +bytemuck = { version = "1.24", features = ["derive"], optional = true } # Frame deduplication (hash-based comparison) -xxhash-rust = { version = "0.8", features = ["xxh64"] } +xxhash-rust = { version = "0.8", features = ["xxh64"], optional = true } # Async channels -async-stream = "0.3" -futures = "0.3" +async-stream = { version = "0.3", optional = true } +futures = { version = "0.3", optional = true } # WebSocket client (for ttyd proxy) -tokio-tungstenite = "0.28" +tokio-tungstenite = { version = "0.28", optional = true } # High-performance synchronization -parking_lot = "0.12" -arc-swap = "1.8" +parking_lot = { version = "0.12", optional = true } +arc-swap = { version = "1.8", optional = true } # WebRTC -webrtc = "0.14" -rtp = "0.14" -rtsp-types = "0.1" -sdp-types = "0.1" +webrtc = { version = "0.14", optional = true } +rtp = { version = "0.14", optional = true } +rtsp-types = { version = "0.1", optional = true } +sdp-types = { version = "0.1", optional = true } # HID (serial port for CH9329) -serialport = "4" -async-trait = "0.1" -libc = "0.2" +serialport = { version = "4", optional = true } +async-trait = { version = "0.1", optional = true } +libc = { version = "0.2", optional = true } # Ventoy bootable image support -ventoy-img = { path = "libs/ventoy-img-rs" } +ventoy-img = { path = "libs/ventoy-img-rs", optional = true } # RustDesk protocol support -protobuf = { version = "3.7", features = ["with-bytes"] } -sodiumoxide = "0.2" -sha2 = "0.10" +protobuf = { version = "3.7", features = ["with-bytes"], optional = true } +sodiumoxide = { version = "0.2", optional = true } +sha2 = { version = "0.10", optional = true } # TypeScript type generation -typeshare = "1.0" +typeshare = { version = "1.0", optional = true } [target.'cfg(any(unix, windows))'.dependencies] # Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux) -hwcodec = { path = "libs/hwcodec" } -libyuv = { path = "res/vcpkg/libyuv" } -turbojpeg = "1.3" +hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true } +libyuv = { path = "res/vcpkg/libyuv", optional = true } +turbojpeg = { version = "1.3", optional = true } # Note: audiopus links to libopus.so (unavoidable for audio support) -audiopus = "0.2" +audiopus = { version = "0.2", optional = true } + +[target.'cfg(all(unix, not(target_os = "android")))'.dependencies] +# Utilities +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) -v4l2r = "0.0.7" +v4l2r = { path = "libs/v4l2r", optional = true } # Audio (ALSA capture) -alsa = "0.11" +alsa = { version = "0.11", optional = true } # ATX (GPIO control) -gpio-cdev = "0.6" +gpio-cdev = { version = "0.6", optional = true } [target.'cfg(windows)'.dependencies] -cpal = { version = "0.17", default-features = false } +cpal = { version = "0.17", default-features = false, optional = true } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_NetworkManagement_IpHelper", @@ -129,7 +255,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Networking_WinSock", "Win32_System_SystemInformation", "Win32_System_Threading", -] } +], optional = true } [dev-dependencies] tempfile = "3" diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..957f56d0 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +build/ +local.properties +app/build/ +app/src/main/jniLibs/ +native/target/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..8c056d5c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,566 @@ +import org.gradle.api.tasks.Exec +import java.security.MessageDigest +import java.util.Properties + +plugins { + id("com.android.application") +} + +val androidNdkVersion = "27.3.13750724" +val androidApiLevel = 21 +val nativeCrateDir = layout.projectDirectory.dir("../native") +val rootCrateDir = layout.projectDirectory.dir("../..") +val nativeCargoOutputDir = layout.buildDirectory.dir("generated/oneKvm/cargoJniLibs") +val nativeOutputRoot = layout.buildDirectory.dir("generated/oneKvm/jniLibs") +val nativeAssetRoot = layout.buildDirectory.dir("generated/oneKvm/assets") +val defaultAndroidFfmpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-ffmpeg-mediacodec") +val defaultAndroidLibyuvRoot = rootProject.layout.projectDirectory.dir("../dist/android-libyuv") +val defaultAndroidTurbojpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-turbojpeg") +val defaultAndroidAlsaRoot = rootProject.layout.projectDirectory.dir("../dist/android-alsa") +val defaultAndroidOpusRoot = rootProject.layout.projectDirectory.dir("../dist/android-opus") +val androidFfmpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_FFMPEG_ROOT") + .orElse(defaultAndroidFfmpegRoot.asFile.absolutePath) +val androidLibyuvRoot = providers.environmentVariable("ONE_KVM_ANDROID_LIBYUV_ROOT") + .orElse(defaultAndroidLibyuvRoot.asFile.absolutePath) +val androidTurbojpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_TURBOJPEG_ROOT") + .orElse(defaultAndroidTurbojpegRoot.asFile.absolutePath) +val androidAlsaRoot = providers.environmentVariable("ONE_KVM_ANDROID_ALSA_ROOT") + .orElse(defaultAndroidAlsaRoot.asFile.absolutePath) +val androidOpusRoot = providers.environmentVariable("ONE_KVM_ANDROID_OPUS_ROOT") + .orElse(defaultAndroidOpusRoot.asFile.absolutePath) +val selectedAndroidAbis = providers.environmentVariable("ONE_KVM_ANDROID_ABIS") + .orElse("arm64-v8a,armeabi-v7a") + .get() + .split(',', ' ', ';') + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() +val androidBuildProfile = providers.environmentVariable("ONE_KVM_ANDROID_PROFILE") + .orElse("debug") + .get() + .lowercase() +val androidFfmpegSourceDir = rootProject.layout.projectDirectory + .dir("../.tmp/android-ffmpeg-check/src/ffmpeg-rockchip") +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } +} +val androidSdkDir = file( + providers.environmentVariable("ANDROID_HOME") + .orElse(providers.environmentVariable("ANDROID_SDK_ROOT")) + .orElse(localProperties.getProperty("sdk.dir") ?: "/root/android-sdk") + .get(), +) +val androidNdkDir = androidSdkDir.resolve("ndk/$androidNdkVersion") + +val androidFfmpegBuildScript = rootProject.layout.projectDirectory + .dir("..") + .file("scripts/build-android-ffmpeg-mediacodec.sh") +val androidLibyuvBuildScript = rootProject.layout.projectDirectory + .dir("..") + .file("scripts/build-android-libyuv.sh") +val androidTurbojpegBuildScript = rootProject.layout.projectDirectory + .dir("..") + .file("scripts/build-android-turbojpeg.sh") +val androidAlsaBuildScript = rootProject.layout.projectDirectory + .dir("..") + .file("scripts/build-android-alsa.sh") +val androidOpusBuildScript = rootProject.layout.projectDirectory + .dir("..") + .file("scripts/build-android-opus.sh") + +val androidAbiTargets = mapOf( + "arm64-v8a" to Triple("arm64", "aarch64-linux-android", "aarch64-linux-android"), + "armeabi-v7a" to Triple("arm32", "armv7-linux-androideabi", "arm-linux-androideabi"), +) + +val selectedAndroidAbiTargets = selectedAndroidAbis.associateWith { abi -> + androidAbiTargets[abi] ?: throw GradleException( + "Unsupported ONE_KVM_ANDROID_ABIS entry: $abi. Supported values: ${androidAbiTargets.keys.joinToString(", ")}", + ) +} + +if (androidBuildProfile != "debug" && androidBuildProfile != "release") { + throw GradleException("Unsupported ONE_KVM_ANDROID_PROFILE: $androidBuildProfile. Use debug or release.") +} + +fun androidFfmpegBuildStamp(script: File): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(script.readBytes()) + .joinToString("") { "%02x".format(it) } + return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest" +} + +fun androidFfmpegRequiredFiles(root: File): List = 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 = 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 = 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 = listOf( + "include/alsa/asoundlib.h", + "lib/libasound.so", +).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } } + +fun androidOpusRequiredFiles(root: File): List = listOf( + "include/opus/opus.h", + "lib/libopus.so", +).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } } + +android { + namespace = "cn.one_kvm.androidhost" + compileSdk = 36 + ndkVersion = androidNdkVersion + flavorDimensions += "abi" + + defaultConfig { + applicationId = "cn.one_kvm.androidhost" + minSdk = androidApiLevel + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + productFlavors { + create("arm32") { + dimension = "abi" + ndk { + abiFilters += "armeabi-v7a" + } + } + create("arm64") { + dimension = "abi" + ndk { + abiFilters += "arm64-v8a" + } + } + } + + sourceSets { + getByName("main") { + assets.directories.clear() + jniLibs.directories.clear() + } + getByName("arm32") { + assets.directories.add("build/generated/oneKvm/assets/arm32") + jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm32") + } + getByName("arm64") { + assets.directories.add("build/generated/oneKvm/assets/arm64") + jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm64") + } + } +} + +tasks.register("buildAndroidFfmpegMediaCodec") { + description = "Builds the default Android FFmpeg MediaCodec static libraries." + group = "build" + + val ffmpegRoot = file(androidFfmpegRoot.get()) + val sourceDir = androidFfmpegSourceDir.asFile + val scriptFile = androidFfmpegBuildScript.asFile + val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp") + + workingDir = rootProject.layout.projectDirectory.dir("..").asFile + commandLine( + "bash", + scriptFile.absolutePath, + "--source", + sourceDir.absolutePath, + "--output", + ffmpegRoot.absolutePath, + "--ndk", + androidNdkDir.absolutePath, + "--api", + androidApiLevel.toString(), + "--abis", + selectedAndroidAbis.joinToString(","), + ) + + inputs.dir(sourceDir) + inputs.file(scriptFile) + outputs.dir(ffmpegRoot) + + onlyIf { + val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() } + val hasCurrentBuildStamp = + stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile) + if (!hasAndroidFfmpeg && !sourceDir.resolve("configure").exists()) { + throw GradleException( + "Missing Android FFmpeg MediaCodec build at ${ffmpegRoot.absolutePath}, " + + "and source was not found at ${sourceDir.absolutePath}", + ) + } + !hasAndroidFfmpeg || !hasCurrentBuildStamp + } + + doLast { + stampFile.writeText(androidFfmpegBuildStamp(scriptFile)) + } +} + +tasks.register("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("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("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("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(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") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..891c2456 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/BootReceiver.kt b/android/app/src/main/java/cn/one_kvm/androidhost/BootReceiver.kt new file mode 100644 index 00000000..a89d28ad --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/BootReceiver.kt @@ -0,0 +1,13 @@ +package cn.one_kvm.androidhost + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED && HostSettings.getAutoStart(context)) { + OneKvmService.start(context) + } + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/HostSettings.kt b/android/app/src/main/java/cn/one_kvm/androidhost/HostSettings.kt new file mode 100644 index 00000000..9f4ba5c0 --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/HostSettings.kt @@ -0,0 +1,33 @@ +package cn.one_kvm.androidhost + +import android.content.Context + +object HostSettings { + private const val PREFS = "one_kvm_android" + private const val KEY_AUTO_START = "auto_start" + private const val KEY_CLEAR_EXISTING_OTG = "clear_existing_otg" + + fun getAutoStart(context: Context): Boolean { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_AUTO_START, false) + } + + fun setAutoStart(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_AUTO_START, enabled) + .apply() + } + + fun getClearExistingOtg(context: Context): Boolean { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_CLEAR_EXISTING_OTG, false) + } + + fun setClearExistingOtg(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_CLEAR_EXISTING_OTG, enabled) + .apply() + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/LogConfig.kt b/android/app/src/main/java/cn/one_kvm/androidhost/LogConfig.kt new file mode 100644 index 00000000..6e201fd2 --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/LogConfig.kt @@ -0,0 +1,30 @@ +package cn.one_kvm.androidhost + +import android.content.Context + +object LogConfig { + private const val PREFS = "one_kvm_android" + private const val KEY_LOG_LEVEL = "log_level" + const val DEFAULT_LEVEL = "info" + val LEVELS = arrayOf("error", "warn", "info", "debug", "trace") + + fun getLevel(context: Context): String { + val value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_LOG_LEVEL, DEFAULT_LEVEL) + ?: DEFAULT_LEVEL + return if (LEVELS.contains(value)) value else DEFAULT_LEVEL + } + + fun setLevel(context: Context, level: String) { + val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LOG_LEVEL, safeLevel) + .apply() + } + + fun rustLogFilter(level: String): String { + val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL + return "one_kvm=$safeLevel,hwcodec=$safeLevel,tower_http=$safeLevel,webrtc_sctp=warn" + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt b/android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt new file mode 100644 index 00000000..398d807d --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt @@ -0,0 +1,71 @@ +package cn.one_kvm.androidhost + +import android.content.Context +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +object LogStore { + private const val FLUSH_DELAY_MS = 250L + private const val MAX_BUFFER_CHARS = 64 * 1024 + + private val lock = Any() + private val buffer = StringBuilder() + private val executor = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "OneKvmLogStore").apply { isDaemon = true } + } + + private var logFile: File? = null + private var flushScheduled = false + + fun defaultLogFile(context: Context): File { + return File(File(context.getExternalFilesDir(null), "runtime"), "one-kvm.log") + } + + fun configure(file: File) { + synchronized(lock) { + flushLocked() + file.parentFile?.mkdirs() + file.writeText("") + buffer.clear() + logFile = file + flushScheduled = false + } + } + + fun append(line: String) { + synchronized(lock) { + if (logFile == null) return + + buffer.append(line).append('\n') + if (buffer.length >= MAX_BUFFER_CHARS) { + flushLocked() + return + } + + if (!flushScheduled) { + flushScheduled = true + executor.schedule({ flush() }, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS) + } + } + } + + fun flush() { + synchronized(lock) { + flushLocked() + } + } + + private fun flushLocked() { + val file = logFile ?: return + if (buffer.isEmpty()) { + flushScheduled = false + return + } + + val text = buffer.toString() + buffer.clear() + flushScheduled = false + file.appendText(text) + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt b/android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt new file mode 100644 index 00000000..75a239b5 --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt @@ -0,0 +1,452 @@ +package cn.one_kvm.androidhost + +import android.app.Activity +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.CompoundButton +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.Spinner +import android.widget.Switch +import android.widget.TextView +import java.net.Inet4Address +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.Socket +import java.util.Collections + +class MainActivity : Activity() { + private lateinit var statusValue: TextView + private lateinit var hostActionButton: Button + private lateinit var logLevelSpinner: Spinner + private lateinit var autoStartSwitch: Switch + private lateinit var clearOtgSwitch: Switch + private val statusHandler = Handler(Looper.getMainLooper()) + private var statusPollsRemaining = 0 + private val statusPoller = object : Runnable { + override fun run() { + refreshStatus() + statusPollsRemaining -= 1 + if (statusPollsRemaining > 0) { + statusHandler.postDelayed(this, STATUS_POLL_INTERVAL_MS) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + window.statusBarColor = color("#F8FAFC") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + window.navigationBarColor = color("#F8FAFC") + } + + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(20.dp(), 24.dp(), 20.dp(), 28.dp()) + background = solid("#F8FAFC") + } + + content.addView(startCard()) + content.addView(settingsCard()) + content.addView(infoCard()) + + setContentView(ScrollView(this).apply { + isFillViewport = true + setBackgroundColor(color("#F8FAFC")) + addView(content) + }) + } + + override fun onResume() { + super.onResume() + reconcilePersistedStatus() + refreshStatus() + autoStartSwitch.isChecked = HostSettings.getAutoStart(this) + clearOtgSwitch.isChecked = HostSettings.getClearExistingOtg(this) + } + + override fun onPause() { + statusHandler.removeCallbacks(statusPoller) + super.onPause() + } + + private fun startCard(): View { + return card { + addView(sectionTitle("启动管理")) + addView(TextView(this@MainActivity).apply { + text = "管理本机 One-KVM 服务进程。暂停会停止前台服务并释放运行资源。" + textSize = 14f + setTextColor(color("#64748B")) + setPadding(0, 6.dp(), 0, 14.dp()) + }) + + statusValue = TextView(this@MainActivity).apply { + textSize = 14f + typeface = Typeface.DEFAULT_BOLD + setTextColor(color("#0F172A")) + background = rounded("#EFF6FF", "#BFDBFE", 8) + setPadding(12.dp(), 8.dp(), 12.dp(), 8.dp()) + } + addView(statusValue, matchWrap()) + + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 14.dp(), 0, 0) + hostActionButton = actionButton("启动", primary = true) { toggleHost() } + addView(hostActionButton, matchButton()) + }) + refreshStatus() + } + } + + private fun settingsCard(): View { + return card { + addView(sectionTitle("运行设置")) + + val (autoStartRow, autoStartControl) = settingSwitchRow( + title = "开机自启动", + subtitle = "系统启动完成后自动拉起 One-KVM 前台服务。", + checked = HostSettings.getAutoStart(this@MainActivity), + ) { _, checked -> + HostSettings.setAutoStart(this@MainActivity, checked) + LogStore.append("Boot auto-start ${if (checked) "enabled" else "disabled"}") + } + autoStartSwitch = autoStartControl + addView(autoStartRow) + + addView(divider()) + + val (clearOtgRow, clearOtgControl) = settingSwitchRow( + title = "清除已有 OTG Gadget", + subtitle = "启动 root host 前尝试解绑并删除 configfs 中已有的 USB gadget。", + checked = HostSettings.getClearExistingOtg(this@MainActivity), + ) { _, checked -> + HostSettings.setClearExistingOtg(this@MainActivity, checked) + LogStore.append("Clear existing OTG gadget ${if (checked) "enabled" else "disabled"}") + } + clearOtgSwitch = clearOtgControl + addView(clearOtgRow) + + addView(divider()) + addView(logLevelRow()) + } + } + + private fun infoCard(): View { + return card { + addView(sectionTitle("应用信息")) + addView(infoRow("软件内核版本", kernelVersion())) + addView(infoRow("访问地址", accessAddresses(), selectable = true)) + addView(infoRow("日志文件", LogStore.defaultLogFile(this@MainActivity).absolutePath, selectable = true)) + } + } + + private fun settingSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + listener: CompoundButton.OnCheckedChangeListener, + ): Pair { + 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() + .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 + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/NativeBridge.kt b/android/app/src/main/java/cn/one_kvm/androidhost/NativeBridge.kt new file mode 100644 index 00000000..3437057d --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/NativeBridge.kt @@ -0,0 +1,21 @@ +package cn.one_kvm.androidhost + +import android.content.Context + +object NativeBridge { + init { + System.loadLibrary("one_kvm_android_bootstrap") + } + + external fun initTlsVerifier(context: Context): Int + + external fun setEnv(name: String, value: String): Int + + external fun startHost(dataDir: String, bindAddress: String, port: Int): String + + external fun stopHost(): String + + external fun hostStatus(): String + + external fun kernelVersion(): String +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/OneKvmService.kt b/android/app/src/main/java/cn/one_kvm/androidhost/OneKvmService.kt new file mode 100644 index 00000000..571389c8 --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/OneKvmService.kt @@ -0,0 +1,413 @@ +package cn.one_kvm.androidhost + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.io.InterruptedIOException +import java.util.concurrent.Executors + +class OneKvmService : Service() { + private var rootProcess: Process? = null + private val commandExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "OneKvmServiceCommand") + } + + override fun onCreate() { + super.onCreate() + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action ?: ACTION_START) { + ACTION_STOP -> { + ServiceStatusStore.setStopping(this) + commandExecutor.execute { + stopHostRuntime() + stopSelfResult(startId) + } + return START_NOT_STICKY + } + ACTION_START -> { + ServiceStatusStore.setStarting(this) + startForegroundCompat(NOTIFICATION_ID, notification("启动中")) + commandExecutor.execute { + val currentState = ServiceStatusStore.snapshot(this).state + if (currentState == ServiceStatusStore.STATE_RUNNING && isPortOpen(8080, 100)) { + return@execute + } + val dataDir = File(getExternalFilesDir(null), "runtime") + if (!dataDir.exists()) dataDir.mkdirs() + val result = startRustHost(dataDir) + if (result.startsWith("Running") && !result.contains("start failed", ignoreCase = true)) { + ServiceStatusStore.setRunning(this, "服务已启动") + notificationManager().notify(NOTIFICATION_ID, notification("运行中")) + } else { + ServiceStatusStore.setError(this, "启动失败") + notificationManager().notify(NOTIFICATION_ID, notification("启动失败")) + } + } + } + } + return START_STICKY + } + + override fun onDestroy() { + stopHostRuntime(updateNotification = false) + commandExecutor.shutdownNow() + ServiceStatusStore.setStopped(this) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun notification(state: String): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = createContentIntent(intent) + val builder = createNotificationBuilder() + return builder + .setSmallIcon(R.drawable.ic_stat_one_kvm) + .setContentTitle("One-KVM Android Host") + .setContentText(state) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + "One-KVM Host", + NotificationManager.IMPORTANCE_LOW, + ) + notificationManager().createNotificationChannel(channel) + } + + @Suppress("DEPRECATION") + private fun createNotificationBuilder(): Notification.Builder { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + } + + private fun createContentIntent(intent: Intent): PendingIntent { + val flags = pendingIntentFlags() + return PendingIntent.getActivity(this, 0, intent, flags) + } + + private fun pendingIntentFlags(): Int { + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags = flags or pendingIntentImmutableFlag() + } + return flags + } + + private fun pendingIntentImmutableFlag(): Int { + return try { + PendingIntent::class.java.getField("FLAG_IMMUTABLE").getInt(null) + } catch (_: ReflectiveOperationException) { + 0 + } + } + + private fun notificationManager(): NotificationManager { + return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private fun stopHostRuntime(updateNotification: Boolean = true) { + stopRootHost() + NativeBridge.stopHost() + waitForPortRelease(8080, 2_000) + LogStore.flush() + ServiceStatusStore.setStopped(this) + if (updateNotification) { + notificationManager().notify(NOTIFICATION_ID, notification("已停止")) + } + } + + private fun startRustHost(dataDir: File): String { + val logLevel = LogConfig.getLevel(this) + val rustLog = LogConfig.rustLogFilter(logLevel) + val appLogFile = LogStore.defaultLogFile(this) + LogStore.configure(appLogFile) + val rustLogFile = appLogFile + LogStore.append("Starting One-KVM Rust host, data_dir=${dataDir.absolutePath}, log_level=$logLevel") + val executable = extractHostBinary() + return runCatching { + val tlsInit = NativeBridge.initTlsVerifier(this) + if (tlsInit != 0) { + throw IllegalStateException("rustls platform verifier init failed with code $tlsInit") + } + stopRootHost(executable) + clearExistingOtgGadgetsIfEnabled() + startRootHost(executable, dataDir, rustLog, rustLogFile, logLevel) + LogStore.append("Rust host running as root on port 8080") + "Running as root on port 8080" + }.getOrElse { rootError -> + LogStore.append("Root host unavailable: ${rootError.message ?: rootError::class.java.simpleName}") + configureAlsaEnvironment(executable) + NativeBridge.setEnv("RUST_LOG", rustLog) + NativeBridge.setEnv("ONE_KVM_FFMPEG_LOG", ffmpegLogLevel(logLevel)) + NativeBridge.setEnv("ONE_KVM_ANDROID_LOG_FILE", rustLogFile.absolutePath) + val jniResult = NativeBridge.startHost(dataDir.absolutePath, "0.0.0.0", 8080) + LogStore.append("Rust host running in app process on port 8080: $jniResult") + "Running in app process on port 8080 (${rootError.message ?: "root unavailable"}; $jniResult)" + } + } + + private fun clearExistingOtgGadgetsIfEnabled() { + if (!HostSettings.getClearExistingOtg(this)) return + + val command = """ + root=/sys/kernel/config/usb_gadget + [ -d "${'$'}root" ] || exit 0 + for gadget in "${'$'}root"/*; do + [ -d "${'$'}gadget" ] || continue + [ -w "${'$'}gadget/UDC" ] && echo "" > "${'$'}gadget/UDC" 2>/dev/null || true + find "${'$'}gadget/configs" -type l -delete 2>/dev/null || true + rm -rf "${'$'}gadget" 2>/dev/null || true + done + """.trimIndent() + + runCatching { + ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command) + .redirectErrorStream(true) + .start() + .waitFor() + }.onSuccess { exit -> + LogStore.append("Existing OTG gadget cleanup finished with exit code $exit") + }.onFailure { err -> + LogStore.append("Existing OTG gadget cleanup failed: ${err.message ?: err::class.java.simpleName}") + } + } + + private fun configureAlsaEnvironment(executable: File) { + val binDir = executable.parentFile + ?: throw IllegalStateException("host binary has no parent directory") + val alsaConfigDir = File(binDir, "alsa") + val alsaConfigPath = File(alsaConfigDir, "alsa.conf") + NativeBridge.setEnv("ALSA_CONFIG_DIR", alsaConfigDir.absolutePath) + NativeBridge.setEnv("ALSA_CONFIG_PATH", alsaConfigPath.absolutePath) + } + + private fun extractHostBinary(): File { + val abi = Build.SUPPORTED_ABIS.firstOrNull { it == "arm64-v8a" || it == "armeabi-v7a" } + ?: throw IllegalStateException("unsupported ABI: ${Build.SUPPORTED_ABIS.joinToString()}") + val binDir = File(filesDir, "bin/$abi") + val target = File(binDir, "one-kvm-android-host") + copyAssetIfChanged("bin/$abi/one-kvm-android-host", target) + copyAssetIfChanged("bin/$abi/libc++_shared.so", File(binDir, "libc++_shared.so")) + copyAssetIfChanged("bin/$abi/libasound.so", File(binDir, "libasound.so")) + copyAssetIfChanged("bin/$abi/libopus.so", File(binDir, "libopus.so")) + copyAssetDirectoryIfChanged("bin/$abi/alsa", File(binDir, "alsa")) + if (!target.setExecutable(true, false)) { + throw IllegalStateException("cannot mark host binary executable") + } + return target + } + + private fun copyAssetIfChanged(assetPath: String, target: File) { + val stamp = File(target.parentFile, "${target.name}.stamp") + @Suppress("DEPRECATION") + val packageInfo = packageManager.getPackageInfo(packageName, 0) + val expectedStamp = "${packageInfo.lastUpdateTime}:$assetPath" + if (target.exists() && stamp.exists() && stamp.readText() == expectedStamp) return + target.parentFile?.mkdirs() + assets.open(assetPath).use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + stamp.writeText(expectedStamp) + } + + private fun copyAssetDirectoryIfChanged(assetDir: String, targetDir: File) { + @Suppress("DEPRECATION") + val packageInfo = packageManager.getPackageInfo(packageName, 0) + val stamp = File(targetDir, ".stamp") + val expectedStamp = "${packageInfo.lastUpdateTime}:$assetDir" + if (targetDir.exists() && stamp.exists() && stamp.readText() == expectedStamp) return + if (targetDir.exists()) targetDir.deleteRecursively() + copyAssetDirectory(assetDir, targetDir) + stamp.writeText(expectedStamp) + } + + private fun copyAssetDirectory(assetDir: String, targetDir: File) { + targetDir.mkdirs() + val children = assets.list(assetDir)?.filter { it.isNotEmpty() }.orEmpty() + for (child in children) { + val childAsset = "$assetDir/$child" + val childTarget = File(targetDir, child) + val grandChildren = assets.list(childAsset)?.filter { it.isNotEmpty() }.orEmpty() + if (grandChildren.isEmpty()) { + copyAssetIfChanged(childAsset, childTarget) + } else { + copyAssetDirectory(childAsset, childTarget) + } + } + } + + private fun startRootHost( + executable: File, + dataDir: File, + rustLog: String, + rustLogFile: File, + logLevel: String, + ) { + stopRootHost(executable) + waitForPortRelease(8080, 2_000) + val libDir = executable.parentFile?.absolutePath + ?: throw IllegalStateException("host binary has no parent directory") + val alsaConfigDir = File(executable.parentFile, "alsa") + val alsaConfigPath = File(alsaConfigDir, "alsa.conf") + val command = + "export LD_LIBRARY_PATH=${shellQuote(libDir)}:\${LD_LIBRARY_PATH:-}; " + + "export ALSA_CONFIG_DIR=${shellQuote(alsaConfigDir.absolutePath)}; " + + "export ALSA_CONFIG_PATH=${shellQuote(alsaConfigPath.absolutePath)}; " + + "export RUST_LOG=${shellQuote(rustLog)}; " + + "export ONE_KVM_FFMPEG_LOG=${shellQuote(ffmpegLogLevel(logLevel))}; " + + "export ONE_KVM_ANDROID_LOG_FILE=${shellQuote(rustLogFile.absolutePath)}; " + + "${shellQuote(executable.absolutePath)} ${shellQuote(dataDir.absolutePath)} 0.0.0.0 8080" + val process = ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command) + .redirectErrorStream(true) + .start() + rootProcess = process + + Thread { + val readError = runCatching { + BufferedReader(InputStreamReader(process.inputStream)).useLines { lines -> + lines.forEach { + android.util.Log.i("OneKvmService", it) + } + } + }.exceptionOrNull() + if (readError != null && readError !is InterruptedIOException) { + android.util.Log.w("OneKvmService", "Root host log reader stopped", readError) + LogStore.append("Root host log reader stopped: ${readError.message ?: readError::class.java.simpleName}") + } + val exit = runCatching { process.waitFor() }.getOrNull() + if (rootProcess === process && exit != null) { + rootProcess = null + ServiceStatusStore.setError(this, "Root host exited with code $exit") + } + }.start() + + Thread.sleep(500) + val exit = runCatching { process.exitValue() }.getOrNull() + if (exit != null) { + rootProcess = null + throw IllegalStateException("root host exited immediately: $exit") + } + } + + private fun startForegroundCompat(id: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val invoked = runCatching { + val method = Service::class.java.getMethod( + "startForeground", + Int::class.javaPrimitiveType, + Notification::class.java, + Int::class.javaPrimitiveType, + ) + method.invoke(this, id, notification, foregroundServiceTypeConnectedDevice()) + }.isSuccess + if (invoked) return + } + super.startForeground(id, notification) + } + + private fun foregroundServiceTypeConnectedDevice(): Int { + return try { + Service::class.java.getField("FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE").getInt(null) + } catch (_: ReflectiveOperationException) { + 0 + } + } + + private fun stopRootHost(executable: File? = null) { + rootProcess?.destroy() + rootProcess = null + stopRootHostProcess(executable) + } + + private fun stopRootHostProcess(executable: File? = null) { + val command = buildString { + append("pkill -TERM -f '[o]ne-kvm-android-host' 2>/dev/null || true; ") + append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -TERM \"${'$'}pid\" 2>/dev/null || true; done; ") + append("sleep 0.2; ") + append("pkill -KILL -f '[o]ne-kvm-android-host' 2>/dev/null || true; ") + append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -KILL \"${'$'}pid\" 2>/dev/null || true; done; ") + } + + runCatching { + ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command) + .redirectErrorStream(true) + .start() + .waitFor() + }.onFailure { err -> + LogStore.append("Failed to stop stale root host: ${err.message ?: err::class.java.simpleName}") + } + } + + private fun waitForPortRelease(port: Int, timeoutMs: Long) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val inUse = isPortOpen(port, 100) + if (!inUse) return + Thread.sleep(100) + } + } + + private fun isPortOpen(port: Int, timeoutMs: Int): Boolean { + return runCatching { + java.net.Socket().use { socket -> + socket.connect(java.net.InetSocketAddress("127.0.0.1", port), timeoutMs) + } + true + }.getOrDefault(false) + } + + private fun shellQuote(value: String): String { + return "'" + value.replace("'", "'\\''") + "'" + } + + private fun ffmpegLogLevel(level: String): String { + return when (level) { + "trace" -> "trace" + "debug" -> "debug" + "info" -> "info" + "warn" -> "warning" + else -> "error" + } + } + + companion object { + private const val CHANNEL_ID = "one_kvm_host" + private const val NOTIFICATION_ID = 1001 + const val ACTION_START = "cn.one_kvm.androidhost.START" + const val ACTION_STOP = "cn.one_kvm.androidhost.STOP" + + fun start(context: Context) { + val intent = Intent(context, OneKvmService::class.java).setAction(ACTION_START) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.startService(Intent(context, OneKvmService::class.java).setAction(ACTION_STOP)) + } + } +} diff --git a/android/app/src/main/java/cn/one_kvm/androidhost/ServiceStatusStore.kt b/android/app/src/main/java/cn/one_kvm/androidhost/ServiceStatusStore.kt new file mode 100644 index 00000000..d3b819ce --- /dev/null +++ b/android/app/src/main/java/cn/one_kvm/androidhost/ServiceStatusStore.kt @@ -0,0 +1,75 @@ +package cn.one_kvm.androidhost + +import android.content.Context + +object ServiceStatusStore { + private const val PREFS = "one_kvm_android_status" + private const val KEY_STATE = "state" + private const val KEY_MESSAGE = "message" + private const val KEY_UPDATED_AT = "updated_at" + + const val STATE_STOPPED = "stopped" + const val STATE_STARTING = "starting" + const val STATE_RUNNING = "running" + const val STATE_STOPPING = "stopping" + const val STATE_ERROR = "error" + + data class Snapshot( + val state: String, + val message: String, + val updatedAt: Long, + ) { + fun labelText(): String { + return when (state) { + STATE_STARTING -> "启动中" + STATE_RUNNING -> "运行中" + STATE_STOPPING -> "停止中" + STATE_ERROR -> "错误" + else -> "已停止" + } + } + + fun displayText(): String { + val label = labelText() + return if (message.isBlank()) label else "$label:$message" + } + } + + fun setStarting(context: Context, message: String = "正在启动服务") { + write(context, STATE_STARTING, message) + } + + fun setRunning(context: Context, message: String) { + write(context, STATE_RUNNING, message) + } + + fun setStopping(context: Context, message: String = "正在停止服务") { + write(context, STATE_STOPPING, message) + } + + fun setStopped(context: Context, message: String = "服务已停止") { + write(context, STATE_STOPPED, message) + } + + fun setError(context: Context, message: String) { + write(context, STATE_ERROR, message) + } + + fun snapshot(context: Context): Snapshot { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return Snapshot( + state = prefs.getString(KEY_STATE, STATE_STOPPED) ?: STATE_STOPPED, + message = prefs.getString(KEY_MESSAGE, "") ?: "", + updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L), + ) + } + + private fun write(context: Context, state: String, message: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_STATE, state) + .putString(KEY_MESSAGE, message) + .putLong(KEY_UPDATED_AT, System.currentTimeMillis()) + .apply() + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_one_kvm.xml b/android/app/src/main/res/drawable/ic_launcher_one_kvm.xml new file mode 100644 index 00000000..70e6565f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_one_kvm.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_stat_one_kvm.xml b/android/app/src/main/res/drawable/ic_stat_one_kvm.xml new file mode 100644 index 00000000..222fdd18 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat_one_kvm.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..f502aaae --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + One-KVM Android Host + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..d8da6140 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..6eda0fca --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.0.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..bae90346 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..8bdaf60c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2e111328 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 00000000..ef07e016 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/native/Cargo.toml b/android/native/Cargo.toml new file mode 100644 index 00000000..fb2c9f72 --- /dev/null +++ b/android/native/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "one-kvm-android-bootstrap" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +name = "one_kvm_android_bootstrap" +crate-type = ["cdylib"] + +[[bin]] +name = "one-kvm-android-host" +path = "src/bin/one-kvm-android-host.rs" + +[dependencies] +jni = "0.22.4" +one-kvm = { path = "../..", default-features = false, features = ["android", "android-mediacodec"] } +rustls-platform-verifier = "0.7" + +[features] +android-mediacodec = ["one-kvm/android-mediacodec"] diff --git a/android/native/src/bin/one-kvm-android-host.rs b/android/native/src/bin/one-kvm-android-host.rs new file mode 100644 index 00000000..90daea46 --- /dev/null +++ b/android/native/src/bin/one-kvm-android-host.rs @@ -0,0 +1,24 @@ +use one_kvm::runtime::android::{self, AndroidRuntimeConfig}; + +fn main() { + let mut args = std::env::args().skip(1); + let data_dir = args + .next() + .unwrap_or_else(|| "/data/local/tmp/one-kvm".to_string()); + let bind_address = args.next().unwrap_or_else(|| "0.0.0.0".to_string()); + let port = args + .next() + .and_then(|value| value.parse::().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); + } +} diff --git a/android/native/src/lib.rs b/android/native/src/lib.rs new file mode 100644 index 00000000..0c23691a --- /dev/null +++ b/android/native/src/lib.rs @@ -0,0 +1,182 @@ +use jni::errors::{ErrorPolicy, ThrowRuntimeExAndDefault}; +use jni::objects::{JClass, JObject, JString}; +use jni::sys::{jint, jstring}; +use jni::{Env, EnvOutcome, EnvUnowned}; +use one_kvm::runtime::android::{self, AndroidRuntimeConfig}; + +#[derive(Debug)] +struct BridgeError(String); + +impl From for BridgeError { + fn from(err: jni::errors::Error) -> Self { + Self(err.to_string()) + } +} + +impl From 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 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 { + 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, + ) -> jni::errors::Result { + Ok(-1) + } +} + +#[derive(Debug, Default)] +struct StringResultPolicy; + +impl ErrorPolicy 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 { + 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, + ) -> jni::errors::Result { + 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::(|| ()) +} + +#[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::(|| ()) +} + +#[cfg(target_os = "android")] +fn init_tls_verifier(env: &mut Env<'_>, context: JObject<'_>) -> Result { + 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 { + 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::(|| ()); + + env.with_env_no_catch(|env| env.new_string(result)) + .resolve_with::(|| ()) + .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::(|| ()) + .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::(|| ()) + .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::(|| ()) + .into_raw() +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..eb5c19e5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OneKvmAndroidHost" +include(":app") diff --git a/build/build-android.sh b/build/build-android.sh new file mode 100644 index 00000000..df803b12 --- /dev/null +++ b/build/build-android.sh @@ -0,0 +1,88 @@ +#!/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") + if [[ -z "$gradle_distribution_url" ]]; then + gradle_distribution_url="$gradle_distribution_url_cn" + fi + fi + + echo "=== Building Android image: $IMAGE_NAME ===" + docker build \ + -f "$DOCKERFILE" \ + -t "$IMAGE_NAME" \ + "${docker_build_args[@]}" \ + "$PROJECT_ROOT/build/cross" + + echo "=== Building Android APK: $arch ===" + docker run --rm \ + -v "$PROJECT_ROOT:/workspace" \ + -v one-kvm-android-gradle-cache:/root/.gradle \ + -v one-kvm-android-cargo-registry:/root/.cargo/registry \ + -w /workspace \ + -e "CHINAMIRRO=${CHINAMIRRO:-0}" \ + -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 + +APK output: + target/android/ +EOF + ;; +*) + fail "Unknown argument: $1" + ;; +esac diff --git a/build/cross/Dockerfile.android b/build/cross/Dockerfile.android new file mode 100644 index 00000000..b222f56d --- /dev/null +++ b/build/cross/Dockerfile.android @@ -0,0 +1,300 @@ +# Android build image for One-KVM +# Based on Debian 11 for stable toolchain/runtime compatibility + +FROM debian:11 + +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://mirrors.tuna.tsinghua.edu.cn/rustup +ARG RUSTUP_UPDATE_ROOT_CN=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup +ARG CARGO_REGISTRY_CN=sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/ +ARG MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/public +ARG GOOGLE_MAVEN_REPOSITORY_CN=https://maven.aliyun.com/repository/google +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 's|http://deb.debian.org/debian|http://mirrors.tuna.tsinghua.edu.cn/debian|g' \ + -e 's|http://security.debian.org/debian-security|http://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \ + /etc/apt/sources.list; \ + fi + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + 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 = 'tuna'" \ + '[source.tuna]' \ + "registry = '${CARGO_REGISTRY_CN}'" \ + '[registries.tuna]' \ + "index = '${CARGO_REGISTRY_CN}'" \ + > /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.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 <&2' \ + ' exit 1' \ + '}' \ + '' \ + '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/$(basename "${apk/-unsigned.apk/.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"' \ + ' find /root/.gradle/wrapper/dists -name "*.lck" -o -name "*.part" 2>/dev/null | xargs -r rm -f' \ + '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"' \ + '' \ + '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 " 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"] diff --git a/build/cross/Dockerfile.arm64 b/build/cross/Dockerfile.arm64 index 699f96f1..34fbd221 100644 --- a/build/cross/Dockerfile.arm64 +++ b/build/cross/Dockerfile.arm64 @@ -95,6 +95,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj && mkdir build && cd build \ && cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ -DCMAKE_INSTALL_PREFIX=/usr/aarch64-linux-gnu \ + -DCMAKE_INSTALL_LIBDIR=lib \ -DCMAKE_SYSTEM_NAME=Linux \ -DCMAKE_SYSTEM_PROCESSOR=aarch64 \ -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \ @@ -113,6 +114,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \ -DCMAKE_SYSTEM_PROCESSOR=aarch64 \ -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \ -DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \ + -DJPEG_FOUND=TRUE \ + -DJPEG_INCLUDE_DIR=/usr/aarch64-linux-gnu/include \ + -DJPEG_LIBRARY=/usr/aarch64-linux-gnu/lib/libjpeg.a \ + -DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/aarch64-linux-gnu/include" \ + -DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/aarch64-linux-gnu/include" \ && make -j$(nproc) \ && make install \ && rm -rf /tmp/libyuv diff --git a/build/cross/Dockerfile.armv7 b/build/cross/Dockerfile.armv7 index 483013cf..d1df918f 100644 --- a/build/cross/Dockerfile.armv7 +++ b/build/cross/Dockerfile.armv7 @@ -94,6 +94,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj && mkdir build && cd build \ && cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ -DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf \ + -DCMAKE_INSTALL_LIBDIR=lib \ -DCMAKE_SYSTEM_NAME=Linux \ -DCMAKE_SYSTEM_PROCESSOR=arm \ -DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \ @@ -112,6 +113,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \ -DCMAKE_SYSTEM_PROCESSOR=arm \ -DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \ -DCMAKE_CXX_COMPILER=arm-linux-gnueabihf-g++ \ + -DJPEG_FOUND=TRUE \ + -DJPEG_INCLUDE_DIR=/usr/arm-linux-gnueabihf/include \ + -DJPEG_LIBRARY=/usr/arm-linux-gnueabihf/lib/libjpeg.a \ + -DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/arm-linux-gnueabihf/include" \ + -DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/arm-linux-gnueabihf/include" \ && make -j$(nproc) \ && make install \ && rm -rf /tmp/libyuv diff --git a/build/cross/Dockerfile.x86_64 b/build/cross/Dockerfile.x86_64 index 8241931f..c6293949 100644 --- a/build/cross/Dockerfile.x86_64 +++ b/build/cross/Dockerfile.x86_64 @@ -92,6 +92,8 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj && cd /tmp/libjpeg-turbo \ && mkdir build && cd build \ && cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_INSTALL_LIBDIR=lib \ -DENABLE_SHARED=OFF -DENABLE_STATIC=ON \ && make -j$(nproc) \ && make install \ @@ -102,6 +104,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \ && cd /tmp/libyuv \ && mkdir build && cd build \ && cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DJPEG_FOUND=TRUE \ + -DJPEG_INCLUDE_DIR=/usr/local/include \ + -DJPEG_LIBRARY=/usr/local/lib/libjpeg.a \ + -DCMAKE_C_FLAGS="-DHAVE_JPEG -I/usr/local/include" \ + -DCMAKE_CXX_FLAGS="-DHAVE_JPEG -I/usr/local/include" \ && make -j$(nproc) \ && make install \ && rm -rf /tmp/libyuv diff --git a/libs/hwcodec/Cargo.toml b/libs/hwcodec/Cargo.toml index bf727dda..eeb1282e 100644 --- a/libs/hwcodec/Cargo.toml +++ b/libs/hwcodec/Cargo.toml @@ -9,14 +9,16 @@ ignored = ["serde"] [features] default = [] +bytes = ["dep:bytes"] rkmpp = [] [dependencies] log = "0.4" +bytes = { version = "1", optional = true } serde_derive = "1.0" serde = "1.0" serde_json = "1.0" [build-dependencies] cc = "1.0" -bindgen = "0.59" +bindgen = "0.70.1" diff --git a/libs/hwcodec/build.rs b/libs/hwcodec/build.rs index 2bbc811d..95c8297b 100644 --- a/libs/hwcodec/build.rs +++ b/libs/hwcodec/build.rs @@ -21,11 +21,16 @@ fn build_common(builder: &mut Build) { let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); let common_dir = manifest_dir.join("cpp").join("common"); - bindgen::builder() + let mut bindings = bindgen::builder() .header(common_dir.join("common.h").to_string_lossy().to_string()) .header(common_dir.join("callback.h").to_string_lossy().to_string()) - .rustified_enum("*") - .parse_callbacks(Box::new(CommonCallbacks)) + .rustified_enum(".*") + .parse_callbacks(Box::new(CommonCallbacks)); + if target_os == "android" { + print_android_bindgen_env(); + bindings = bindings.clang_args(android_clang_args()); + } + bindings .generate() .unwrap() .write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("common_ffi.rs")) @@ -57,9 +62,9 @@ fn build_common(builder: &mut Build) { } // Unsupported platforms - if target_os != "windows" && target_os != "linux" { + if target_os != "windows" && target_os != "linux" && target_os != "android" { panic!( - "Unsupported OS: {}. Only Windows and Linux are supported.", + "Unsupported OS: {}. Only Windows, Linux, and Android are supported.", target_os ); } @@ -71,9 +76,9 @@ fn build_common(builder: &mut Build) { #[derive(Debug)] struct CommonCallbacks; impl bindgen::callbacks::ParseCallbacks for CommonCallbacks { - fn add_derives(&self, name: &str) -> Vec { + fn add_derives(&self, info: &bindgen::callbacks::DeriveInfo<'_>) -> Vec { let names = vec!["DataFormat", "SurfaceFormat", "API"]; - if names.contains(&name) { + if names.contains(&info.name) { vec!["Serialize", "Deserialize"] .drain(..) .map(|s| s.to_string()) @@ -84,12 +89,123 @@ 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 { + 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::().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 { + let mut entries = std::fs::read_dir(path) + .ok()? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + 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::>(); + entries.sort(); + entries + .pop() + .unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display())) +} + mod ffmpeg { use super::*; pub fn build_ffmpeg(builder: &mut Build) { 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 if let Some(vcpkg_installed) = vcpkg_installed_root() { link_vcpkg(builder, vcpkg_installed); @@ -104,6 +220,67 @@ mod ffmpeg { 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 { println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); @@ -335,7 +512,10 @@ mod ffmpeg { return; } - println!("cargo:warning=Windows QSV support library not found in {}", lib_dir.display()); + println!( + "cargo:warning=Windows QSV support library not found in {}", + lib_dir.display() + ); } fn link_os() { @@ -358,9 +538,11 @@ mod ffmpeg { } // ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2 v + } else if target_os == "android" { + Vec::new() } else { panic!( - "Unsupported OS: {}. Only Windows and Linux are supported.", + "Unsupported OS: {}. Only Windows, Linux, and Android are supported.", target_os ); }; @@ -376,9 +558,14 @@ mod ffmpeg { let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h"); println!("cargo:rerun-if-changed={}", ffi_header_path.display()); let ffi_header = ffi_header_path.to_string_lossy().to_string(); - bindgen::builder() + let mut bindings = bindgen::builder() .header(ffi_header) - .rustified_enum("*") + .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 .generate() .unwrap() .write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ffi.rs")) @@ -392,9 +579,14 @@ mod ffmpeg { .join("ffmpeg_ram_ffi.h") .to_string_lossy() .to_string(); - bindgen::builder() + let mut bindings = bindgen::builder() .header(ffi_header) - .rustified_enum("*") + .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 .generate() .unwrap() .write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ram_ffi.rs")) @@ -405,7 +597,9 @@ mod ffmpeg { // 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. let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); - let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm") + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let enable_rkmpp = target_os != "android" + && matches!(target_arch.as_str(), "aarch64" | "arm") || std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); if enable_rkmpp { builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp")); @@ -431,7 +625,7 @@ mod ffmpeg { .to_string(); bindgen::builder() .header(capture_header) - .rustified_enum("*") + .rustified_enum(".*") .generate() .unwrap() .write_to_file( @@ -454,14 +648,16 @@ mod ffmpeg { .to_string(); bindgen::builder() .header(ffi_header) - .rustified_enum("*") + .rustified_enum(".*") .generate() .unwrap() .write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_hw_ffi.rs")) .unwrap(); let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); - let enable_rkmpp = matches!(target_arch.as_str(), "aarch64" | "arm") + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let enable_rkmpp = target_os != "android" + && matches!(target_arch.as_str(), "aarch64" | "arm") || std::env::var_os("CARGO_FEATURE_RKMPP").is_some(); if enable_rkmpp { // Include RGA headers for NV16->NV12 conversion (RGA im2d API) diff --git a/libs/hwcodec/cpp/common/util.cpp b/libs/hwcodec/cpp/common/util.cpp index 8f5e19a6..08e4f87a 100644 --- a/libs/hwcodec/cpp/common/util.cpp +++ b/libs/hwcodec/cpp/common/util.cpp @@ -100,8 +100,13 @@ void set_av_codec_ctx(AVCodecContext *c, const std::string &name, int kbs, c->color_primaries = AVCOL_PRI_SMPTE170M; c->color_trc = AVCOL_TRC_SMPTE170M; - // WebRTC SDP advertises constrained baseline. Keep hardware and software - // encoders on the same browser-friendly H264 profile. + // WebRTC SDP advertises constrained baseline. Keep most hardware and software + // encoders on the same browser-friendly H264 profile. Android MediaCodec is + // 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) { c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE; } else if (name.find("hevc") != std::string::npos) { @@ -305,23 +310,9 @@ bool set_quality(void *priv_data, const std::string &name, int quality) { break; } } - if (name.find("mediacodec") != std::string::npos) { - if (name.find("h264") != std::string::npos) { - if ((ret = av_opt_set(priv_data, "level", "5.1", 0)) < 0) { - LOG_ERROR(std::string("mediacodec set opt level 5.1 failed, ret = ") + - av_err2str(ret)); - return false; - } - } - if (name.find("hevc") != std::string::npos) { - // https:en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels - if ((ret = av_opt_set(priv_data, "level", "h5.1", 0)) < 0) { - LOG_ERROR(std::string("mediacodec set opt level h5.1 failed, ret = ") + - av_err2str(ret)); - return false; - } - } - } + // 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 if (is_software_h264(name)) { const char* preset = nullptr; @@ -457,6 +448,13 @@ bool set_others(void *priv_data, const std::string &name) { 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. // This was disabling automatic keyframe generation. // The encoder should respect c->gop_size for keyframe interval. diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_decode.cpp b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_decode.cpp index 52a6e2a6..64287dc4 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_decode.cpp +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_decode.cpp @@ -137,6 +137,13 @@ public: 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) { set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret)); return false; diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp index 7051ffa3..c4507a54 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp @@ -112,6 +112,9 @@ _exit: namespace { typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts, int key, const void *obj); +typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data, + int len, int64_t pts, int key, + const void *obj); class FFmpegRamEncoder { public: @@ -134,6 +137,7 @@ public: int thread_count_ = 1; int gpu_ = 0; RamEncodeCallback callback_ = NULL; + RamEncodePacketCallback packet_callback_ = NULL; int offset_[AV_NUM_DATA_POINTERS] = {0}; bool force_keyframe_ = false; // Force next frame to be a keyframe @@ -141,6 +145,7 @@ public: AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE; AVBufferRef *hw_device_ctx_ = NULL; AVFrame *hw_frame_ = NULL; + AVFrame *borrowed_frame_ = NULL; FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height, int pixfmt, int align, int fps, int gop, int rc, int quality, @@ -247,6 +252,11 @@ public: LOG_ERROR(std::string("Could not allocate video packet")); 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 */ c_->width = width_; @@ -297,11 +307,19 @@ public: int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) { 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) { LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret)); return ret; } - if ((ret = fill_frame(frame_, (uint8_t *)data, length, offset_)) != 0) + if ((ret = fill_frame(frame_, data, length, offset_)) != 0) return ret; AVFrame *tmp_frame; if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) { @@ -317,6 +335,14 @@ public: return do_encode(tmp_frame, obj, ms); } + int encode_packet(const uint8_t *data, int length, const void *obj, + uint64_t ms, RamEncodePacketCallback callback) { + packet_callback_ = callback; + int ret = encode(data, length, obj, ms); + packet_callback_ = NULL; + return ret; + } + void free_encoder() { if (pkt_) av_packet_free(&pkt_); @@ -324,6 +350,8 @@ public: av_frame_free(&frame_); if (hw_frame_) av_frame_free(&hw_frame_); + if (borrowed_frame_) + av_frame_free(&borrowed_frame_); if (hw_device_ctx_) av_buffer_unref(&hw_device_ctx_); if (c_) @@ -376,101 +404,203 @@ private: frame->pict_type = AV_PICTURE_TYPE_NONE; } - if ((ret = avcodec_send_frame(c_, frame)) < 0) { + ret = avcodec_send_frame(c_, frame); + if (ret == AVERROR(EAGAIN)) { + int drain_ret = receive_available_packets(obj, encoded); + if (drain_ret < 0) { + return drain_ret; + } + ret = avcodec_send_frame(c_, frame); + } + if (ret == AVERROR(EAGAIN)) { + return encoded ? 0 : AVERROR(EAGAIN); + } + if (ret < 0) { LOG_ERROR(std::string("avcodec_send_frame failed, ret = ") + av_err2str(ret)); return ret; } - auto start = util::now(); - while (ret >= 0 && util::elapsed_ms(start) < DECODE_TIMEOUT_MS) { - if ((ret = avcodec_receive_packet(c_, pkt_)) < 0) { - if (ret != AVERROR(EAGAIN)) { - LOG_ERROR(std::string("avcodec_receive_packet failed, ret = ") + av_err2str(ret)); - } - goto _exit; - } - if (!pkt_->data || !pkt_->size) { - LOG_ERROR(std::string("avcodec_receive_packet failed, pkt size is 0")); - goto _exit; - } - encoded = true; - callback_(pkt_->data, pkt_->size, pkt_->pts, - pkt_->flags & AV_PKT_FLAG_KEY, obj); + ret = receive_available_packets(obj, encoded); + if (ret < 0) { + return ret; } - _exit: - av_packet_unref(pkt_); + // If no packet is produced for this input frame, treat it as EAGAIN. // This is not a fatal error: encoders may buffer internally (e.g., startup delay). return encoded ? 0 : AVERROR(EAGAIN); } - int fill_frame(AVFrame *frame, uint8_t *data, int data_length, - const int *const offset) { + int receive_available_packets(const void *obj, bool &encoded) { + int ret = 0; + auto start = util::now(); + + while (util::elapsed_ms(start) < DECODE_TIMEOUT_MS) { + ret = avcodec_receive_packet(c_, pkt_); + if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { + return 0; + } + if (ret < 0) { + LOG_ERROR(std::string("avcodec_receive_packet failed, ret = ") + av_err2str(ret)); + av_packet_unref(pkt_); + return ret; + } + if (!pkt_->data || !pkt_->size) { + LOG_WARN(std::string("avcodec_receive_packet returned empty packet")); + av_packet_unref(pkt_); + continue; + } + encoded = true; + if (packet_callback_) { + AVPacket *owned_pkt = av_packet_clone(pkt_); + if (!owned_pkt) { + LOG_ERROR("av_packet_clone failed"); + av_packet_unref(pkt_); + return AVERROR(ENOMEM); + } + packet_callback_(owned_pkt, owned_pkt->data, owned_pkt->size, + owned_pkt->pts, owned_pkt->flags & AV_PKT_FLAG_KEY, + obj); + } else { + callback_(pkt_->data, pkt_->size, pkt_->pts, + pkt_->flags & AV_PKT_FLAG_KEY, obj); + } + av_packet_unref(pkt_); + } + + return 0; + } + + int copy_plane(uint8_t *dst, int dst_stride, const uint8_t *src, + int src_stride, int row_bytes, int rows) { + if (!dst || !src || dst_stride < row_bytes || src_stride < row_bytes) { + return -1; + } + if (rows <= 0 || row_bytes <= 0) { + return 0; + } + if (dst_stride == row_bytes && src_stride == row_bytes) { + memcpy(dst, src, static_cast(row_bytes) * rows); + return 0; + } + for (int y = 0; y < rows; y++) { + memcpy(dst + y * dst_stride, src + y * src_stride, row_bytes); + } + return 0; + } + + int fill_frame(AVFrame *frame, const uint8_t *data, int data_length, + const int *const) { + const int src_y_stride = width_; + const int src_packed_stride = width_ * bytes_per_pixel(frame->format); + const int src_uv_stride = width_; + const int src_y_size = width_ * frame->height; + const int src_420_chroma_size = (width_ / 2) * (frame->height / 2); switch (frame->format) { case AV_PIX_FMT_NV12: case AV_PIX_FMT_NV21: if (data_length < - frame->height * (frame->linesize[0] + frame->linesize[1] / 2)) { + frame->height * src_y_stride + frame->height / 2 * src_uv_stride) { LOG_ERROR(std::string("fill_frame: NV12/NV21 data length error. data_length:") + std::to_string(data_length) + - ", linesize[0]:" + std::to_string(frame->linesize[0]) + - ", linesize[1]:" + std::to_string(frame->linesize[1])); + ", width:" + std::to_string(width_) + + ", height:" + std::to_string(frame->height)); + return -1; + } + if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride, + width_, frame->height) != 0 || + copy_plane(frame->data[1], frame->linesize[1], data + src_y_size, + src_uv_stride, width_, frame->height / 2) != 0) { + LOG_ERROR("fill_frame: NV12/NV21 copy failed"); return -1; } - frame->data[0] = data; - frame->data[1] = data + offset[0]; break; case AV_PIX_FMT_NV16: - case AV_PIX_FMT_NV24: if (data_length < - frame->height * (frame->linesize[0] + frame->linesize[1])) { - LOG_ERROR(std::string("fill_frame: NV16/NV24 data length error. data_length:") + + frame->height * src_y_stride + frame->height * src_uv_stride) { + LOG_ERROR(std::string("fill_frame: NV16 data length error. data_length:") + std::to_string(data_length) + - ", linesize[0]:" + std::to_string(frame->linesize[0]) + - ", linesize[1]:" + std::to_string(frame->linesize[1])); + ", width:" + std::to_string(width_) + + ", height:" + std::to_string(frame->height)); + return -1; + } + if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride, + width_, frame->height) != 0 || + copy_plane(frame->data[1], frame->linesize[1], data + src_y_size, + src_uv_stride, width_, frame->height) != 0) { + LOG_ERROR("fill_frame: NV16 copy failed"); return -1; } - frame->data[0] = data; - frame->data[1] = data + offset[0]; break; + case AV_PIX_FMT_NV24: { + const int src_nv24_uv_stride = width_ * 2; + if (data_length < + frame->height * src_y_stride + frame->height * src_nv24_uv_stride) { + LOG_ERROR(std::string("fill_frame: NV24 data length error. data_length:") + + std::to_string(data_length) + + ", width:" + std::to_string(width_) + + ", height:" + std::to_string(frame->height)); + return -1; + } + if (copy_plane(frame->data[0], frame->linesize[0], data, src_y_stride, + width_, frame->height) != 0 || + copy_plane(frame->data[1], frame->linesize[1], data + src_y_size, + src_nv24_uv_stride, width_ * 2, frame->height) != 0) { + LOG_ERROR("fill_frame: NV24 copy failed"); + return -1; + } + break; + } case AV_PIX_FMT_YUV420P: if (data_length < - frame->height * (frame->linesize[0] + frame->linesize[1] / 2 + - frame->linesize[2] / 2)) { + width_ * frame->height + (width_ / 2) * (frame->height / 2) * 2) { LOG_ERROR(std::string("fill_frame: 420P data length error. data_length:") + std::to_string(data_length) + - ", linesize[0]:" + std::to_string(frame->linesize[0]) + - ", linesize[1]:" + std::to_string(frame->linesize[1]) + - ", linesize[2]:" + std::to_string(frame->linesize[2])); + ", width:" + std::to_string(width_) + + ", height:" + std::to_string(frame->height)); + return -1; + } + if (copy_plane(frame->data[0], frame->linesize[0], data, width_, + width_, frame->height) != 0 || + copy_plane(frame->data[1], frame->linesize[1], data + src_y_size, + width_ / 2, width_ / 2, frame->height / 2) != 0 || + copy_plane(frame->data[2], frame->linesize[2], + data + src_y_size + src_420_chroma_size, + width_ / 2, width_ / 2, frame->height / 2) != 0) { + LOG_ERROR("fill_frame: 420P copy failed"); return -1; } - frame->data[0] = data; - frame->data[1] = data + offset[0]; - frame->data[2] = data + offset[1]; break; case AV_PIX_FMT_YUYV422: case AV_PIX_FMT_YVYU422: case AV_PIX_FMT_UYVY422: // Packed YUV 4:2:2 formats: single plane, linesize[0] = width * 2 - if (data_length < frame->height * frame->linesize[0]) { + if (data_length < frame->height * src_packed_stride) { LOG_ERROR(std::string("fill_frame: YUYV422 data length error. data_length:") + std::to_string(data_length) + - ", linesize[0]:" + std::to_string(frame->linesize[0]) + + ", stride:" + std::to_string(src_packed_stride) + ", height:" + std::to_string(frame->height)); return -1; } - frame->data[0] = data; + if (copy_plane(frame->data[0], frame->linesize[0], data, + src_packed_stride, src_packed_stride, frame->height) != 0) { + LOG_ERROR("fill_frame: YUYV422 copy failed"); + return -1; + } break; case AV_PIX_FMT_RGB24: case AV_PIX_FMT_BGR24: - if (data_length < frame->height * frame->linesize[0]) { + if (data_length < frame->height * src_packed_stride) { LOG_ERROR(std::string("fill_frame: RGB24/BGR24 data length error. data_length:") + std::to_string(data_length) + - ", linesize[0]:" + std::to_string(frame->linesize[0]) + + ", stride:" + std::to_string(src_packed_stride) + ", height:" + std::to_string(frame->height)); return -1; } - frame->data[0] = data; + if (copy_plane(frame->data[0], frame->linesize[0], data, + src_packed_stride, src_packed_stride, frame->height) != 0) { + LOG_ERROR("fill_frame: RGB24/BGR24 copy failed"); + return -1; + } break; default: LOG_ERROR(std::string("fill_frame: unsupported format, ") + @@ -479,6 +609,79 @@ private: } 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(data); + borrowed_frame_->data[1] = const_cast(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(data); + borrowed_frame_->data[1] = const_cast(data + y_size); + borrowed_frame_->data[2] = const_cast(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) { + switch (pix_fmt) { + case AV_PIX_FMT_YUYV422: + case AV_PIX_FMT_YVYU422: + case AV_PIX_FMT_UYVY422: + return 2; + case AV_PIX_FMT_RGB24: + case AV_PIX_FMT_BGR24: + return 3; + default: + return 1; + } + } }; } // namespace @@ -532,6 +735,25 @@ extern "C" void ffmpeg_ram_free_encoder(FFmpegRamEncoder *encoder) { } } +extern "C" int ffmpeg_ram_encode_packet(FFmpegRamEncoder *encoder, + const uint8_t *data, int length, + const void *obj, uint64_t ms, + RamEncodePacketCallback callback) { + try { + return encoder->encode_packet(data, length, obj, ms, callback); + } catch (const std::exception &e) { + LOG_ERROR(std::string("encode_packet failed, ") + std::string(e.what())); + return -1; + } +} + +extern "C" void ffmpeg_ram_free_packet(void *packet) { + AVPacket *pkt = reinterpret_cast(packet); + if (pkt) { + av_packet_free(&pkt); + } +} + extern "C" int ffmpeg_ram_set_bitrate(FFmpegRamEncoder *encoder, int kbs) { try { return encoder->set_bitrate(kbs); diff --git a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_ffi.h b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_ffi.h index 60c45b3a..e728fd85 100644 --- a/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_ffi.h +++ b/libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_ffi.h @@ -7,6 +7,9 @@ typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts, int key, const void *obj); +typedef void (*RamEncodePacketCallback)(void *packet, const uint8_t *data, + int len, int64_t pts, int key, + const void *obj); typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width, int height, int pixfmt, const void *obj); @@ -18,7 +21,11 @@ void *ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width, RamEncodeCallback callback); int ffmpeg_ram_encode(void *encoder, const uint8_t *data, int length, const void *obj, int64_t ms); +int ffmpeg_ram_encode_packet(void *encoder, const uint8_t *data, int length, + const void *obj, int64_t ms, + RamEncodePacketCallback callback); void ffmpeg_ram_free_encoder(void *encoder); +void ffmpeg_ram_free_packet(void *packet); int ffmpeg_ram_get_linesize_offset_length(int pix_fmt, int width, int height, int align, int *linesize, int *offset, int *length); diff --git a/libs/hwcodec/src/common.rs b/libs/hwcodec/src/common.rs index b545d9ef..ed01375a 100644 --- a/libs/hwcodec/src/common.rs +++ b/libs/hwcodec/src/common.rs @@ -13,7 +13,7 @@ pub enum Driver { FFMPEG, } -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(windows, target_os = "linux", target_os = "android"))] pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) { #[cfg(target_os = "linux")] use std::ffi::c_int; @@ -39,6 +39,8 @@ pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) { linux_support_amd() == 0, linux_support_intel() == 0, ); + #[cfg(target_os = "android")] + return (false, false, false); #[allow(unreachable_code)] (false, false, false) } diff --git a/libs/hwcodec/src/ffmpeg_ram/encode.rs b/libs/hwcodec/src/ffmpeg_ram/encode.rs index 64d3d279..a39edbc4 100644 --- a/libs/hwcodec/src/ffmpeg_ram/encode.rs +++ b/libs/hwcodec/src/ffmpeg_ram/encode.rs @@ -2,11 +2,13 @@ use crate::{ common::DataFormat::{self, *}, ffmpeg::{init_av_log, AVPixelFormat}, ffmpeg_ram::{ - ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_free_encoder, - ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, - AV_NUM_DATA_POINTERS, + 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_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS, }, }; +#[cfg(feature = "bytes")] +use bytes::Bytes; use log::trace; use std::{ ffi::{c_void, CString}, @@ -15,7 +17,7 @@ use std::{ slice, }; -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(windows, target_os = "linux", target_os = "android"))] use crate::common::Driver; /// Timeout for encoder test in milliseconds @@ -26,6 +28,7 @@ const PRIORITY_AMF: i32 = 2; const PRIORITY_RKMPP: i32 = 3; const PRIORITY_VAAPI: i32 = 4; const PRIORITY_V4L2M2M: i32 = 5; +const PRIORITY_MEDIACODEC: i32 = 2; #[derive(Clone, Copy)] struct CandidateCodecSpec { @@ -92,11 +95,32 @@ fn linux_support_v4l2m2m() -> bool { false } -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(windows, target_os = "linux", target_os = "android"))] fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec { use log::debug; 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| { // Without VRAM feature, we can't check SDK availability. // Keep the prefilter coarse and let FFmpeg validation do the real check. @@ -257,7 +281,13 @@ struct ProbePolicy { impl ProbePolicy { fn for_codec(codec_name: &str) -> Self { - if codec_name.contains("amf") { + if codec_name.contains("mediacodec") { + Self { + max_attempts: 30, + request_keyframe: true, + accept_any_output: true, + } + } else if codec_name.contains("amf") { Self { max_attempts: 5, request_keyframe: true, @@ -304,11 +334,11 @@ fn log_failed_probe_attempt( frames: &[EncodeFrame], elapsed_ms: u128, ) { - use log::debug; + use log::{debug, trace}; if policy.accept_any_output { if frames.is_empty() { - debug!( + trace!( "Encoder {} test produced no output on attempt {}", codec_name, attempt ); @@ -337,7 +367,7 @@ fn log_failed_probe_attempt( } fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool { - use log::debug; + use log::{debug, warn}; debug!("Testing encoder: {}", codec.name); @@ -388,13 +418,13 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo ); } } - Err(err) => { - last_err = Some(err); - debug!( - "Encoder {} test attempt {} returned error: {}", - codec.name, attempt_no, err - ); - } + Err(err) => { + last_err = Some(err); + warn!( + "Encoder {} test attempt {} returned error: {}", + codec.name, attempt_no, err + ); + } } } @@ -407,16 +437,20 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo ); false } - Err(_) => { - debug!("Failed to create encoder {}", codec.name); - false - } - } + Err(_) => { + warn!("Failed to create encoder {}", codec.name); + false + } + } } fn add_software_fallback(codecs: &mut Vec) { use log::debug; + if cfg!(target_os = "android") { + return; + } + for fallback in CodecInfo::soft().into_vec() { if !codecs.iter().any(|codec| codec.format == fallback.format) { debug!( @@ -451,6 +485,39 @@ pub struct EncodeFrame { pub key: i32, } +#[cfg(feature = "bytes")] +pub struct EncodeBytesFrame { + pub data: Bytes, + pub pts: i64, + pub key: i32, +} + +#[cfg(feature = "bytes")] +struct FfmpegPacketOwner { + packet: *mut c_void, + data: *const u8, + len: usize, +} + +#[cfg(feature = "bytes")] +unsafe impl Send for FfmpegPacketOwner {} + +#[cfg(feature = "bytes")] +impl AsRef<[u8]> for FfmpegPacketOwner { + fn as_ref(&self) -> &[u8] { + unsafe { slice::from_raw_parts(self.data, self.len) } + } +} + +#[cfg(feature = "bytes")] +impl Drop for FfmpegPacketOwner { + fn drop(&mut self) { + unsafe { + ffmpeg_ram_free_packet(self.packet); + } + } +} + impl Display for EncodeFrame { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "encode len:{}, pts:{}", self.data.len(), self.pts) @@ -543,6 +610,25 @@ impl Encoder { } } + #[cfg(feature = "bytes")] + pub fn encode_bytes(&mut self, data: &[u8], ms: i64) -> Result, i32> { + unsafe { + let mut frames = Vec::::new(); + let result = ffmpeg_ram_encode_packet( + self.codec, + data.as_ptr(), + data.len() as _, + &mut frames as *mut _ as *const c_void, + ms, + Some(Encoder::packet_callback), + ); + if result == -11 || result == 0 { + return Ok(frames); + } + Err(result) + } + } + extern "C" fn callback(data: *const u8, size: c_int, pts: i64, key: i32, obj: *const c_void) { unsafe { let frames = &mut *(obj as *mut Vec); @@ -554,6 +640,30 @@ impl Encoder { } } + #[cfg(feature = "bytes")] + extern "C" fn packet_callback( + packet: *mut c_void, + data: *const u8, + size: c_int, + pts: i64, + key: i32, + obj: *const c_void, + ) { + unsafe { + let frames = &mut *(obj as *mut Vec); + let owner = FfmpegPacketOwner { + packet, + data, + len: size as usize, + }; + frames.push(EncodeBytesFrame { + data: Bytes::from_owner(owner), + pts, + key, + }); + } + } + pub fn set_bitrate(&mut self, kbs: i32) -> Result<(), ()> { let ret = unsafe { ffmpeg_ram_set_bitrate(self.codec, kbs) }; if ret == 0 { @@ -588,11 +698,11 @@ impl Encoder { pub fn available_encoders(ctx: EncodeContext, _sdk: Option) -> Vec { use log::debug; - if !(cfg!(windows) || cfg!(target_os = "linux")) { + if !(cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "android")) { return vec![]; } let mut res = vec![]; - #[cfg(any(windows, target_os = "linux"))] + #[cfg(any(windows, target_os = "linux", target_os = "android"))] let codecs = enumerate_candidate_codecs(&ctx); if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) { diff --git a/libs/hwcodec/src/ffmpeg_ram/mod.rs b/libs/hwcodec/src/ffmpeg_ram/mod.rs index def1ed62..47a90399 100644 --- a/libs/hwcodec/src/ffmpeg_ram/mod.rs +++ b/libs/hwcodec/src/ffmpeg_ram/mod.rs @@ -9,12 +9,18 @@ use std::ffi::c_int; include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs")); -#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))] +#[cfg(all( + any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"), + not(target_os = "android") +))] pub mod decode; // 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). -#[cfg(not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")))] +#[cfg(any( + not(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp")), + target_os = "android" +))] pub mod decode { use crate::ffmpeg::AVPixelFormat; diff --git a/libs/hwcodec/src/lib.rs b/libs/hwcodec/src/lib.rs index 9a57fa75..7dc82340 100644 --- a/libs/hwcodec/src/lib.rs +++ b/libs/hwcodec/src/lib.rs @@ -2,7 +2,10 @@ pub mod capture; pub mod common; pub mod ffmpeg; -#[cfg(any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"))] +#[cfg(all( + any(target_arch = "aarch64", target_arch = "arm", feature = "rkmpp"), + not(target_os = "android") +))] pub mod ffmpeg_hw; pub mod ffmpeg_ram; diff --git a/libs/v4l2r/bindgen.rs b/libs/v4l2r/bindgen.rs index 8d09e96f..c5ab0946 100644 --- a/libs/v4l2r/bindgen.rs +++ b/libs/v4l2r/bindgen.rs @@ -16,7 +16,5 @@ impl bindgen::callbacks::ParseCallbacks for Fix753 { fn v4l2r_bindgen_builder(builder: bindgen::Builder) -> bindgen::Builder { builder .parse_callbacks(Box::new(Fix753)) - .derive_partialeq(true) - .derive_eq(true) .derive_default(true) } diff --git a/res/vcpkg/libyuv/Cargo.toml b/res/vcpkg/libyuv/Cargo.toml index e7ef3a62..6ec2b467 100644 --- a/res/vcpkg/libyuv/Cargo.toml +++ b/res/vcpkg/libyuv/Cargo.toml @@ -8,4 +8,4 @@ license = "BSD-3-Clause" [dependencies] [build-dependencies] -bindgen = "0.59" +bindgen = "0.70.1" diff --git a/res/vcpkg/libyuv/build.rs b/res/vcpkg/libyuv/build.rs index 7ceb3363..d0358453 100644 --- a/res/vcpkg/libyuv/build.rs +++ b/res/vcpkg/libyuv/build.rs @@ -19,9 +19,19 @@ fn main() { fn generate_bindings(cpp_dir: &Path) { let ffi_header = cpp_dir.join("yuv_ffi.h"); + let mut builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string()); - 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 // YUYV conversions .allowlist_function("YUY2ToI420") .allowlist_function("YUY2ToNV12") @@ -38,6 +48,7 @@ fn generate_bindings(cpp_dir: &Path) { // NV12/NV21 conversions .allowlist_function("NV12ToI420") .allowlist_function("NV21ToI420") + .allowlist_function("NV21ToNV12") .allowlist_function("NV12Copy") .allowlist_function("SplitUVPlane") // ARGB/BGRA conversions @@ -49,6 +60,7 @@ fn generate_bindings(cpp_dir: &Path) { .allowlist_function("ABGRToARGB") // RGB24/BGR24 conversions .allowlist_function("RGB24ToI420") + .allowlist_function("RGB24ToNV12") .allowlist_function("RAWToI420") .allowlist_function("RGB24ToARGB") .allowlist_function("RAWToARGB") @@ -62,6 +74,9 @@ fn generate_bindings(cpp_dir: &Path) { .allowlist_function("UYVYToARGB") .allowlist_function("ARGBToRGB24") .allowlist_function("ARGBToRAW") + // MJPEG decoding + .allowlist_function("MJPGToNV12") + .allowlist_function("MJPGSize") // Scaling .allowlist_function("I420Scale") .allowlist_function("NV12Scale") @@ -81,6 +96,30 @@ fn generate_bindings(cpp_dir: &Path) { } 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//include\n\ + $ONE_KVM_ANDROID_LIBYUV_ROOT//lib/libyuv.a" + ); + } + // Try vcpkg first if let Some(vcpkg_installed) = vcpkg_installed_root() { if link_vcpkg(vcpkg_installed) { @@ -109,6 +148,217 @@ 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 { + 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::().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 { + let mut entries = std::fs::read_dir(path) + .ok()? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + 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::>(); + entries.sort(); + entries + .pop() + .unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display())) +} + fn vcpkg_installed_root() -> Option { println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR"); println!("cargo:rerun-if-env-changed=VCPKG_ROOT"); @@ -133,6 +383,10 @@ fn link_vcpkg(mut path: PathBuf) -> bool { ("linux", "x86_64") => "x64-linux", ("linux", "aarch64") => "arm64-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") => "x86-windows-static", ("macos", "x86_64") => "x64-osx", @@ -169,14 +423,21 @@ fn link_vcpkg(mut path: PathBuf) -> bool { if use_static && static_lib.exists() { // Static linking (for deb packaging) println!("cargo:rustc-link-lib=static=yuv"); - #[cfg(target_os = "linux")] - println!("cargo:rustc-link-lib=stdc++"); + link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os); + if target_os == "linux" { + 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)"); } else { // Dynamic linking (default for development) println!("cargo:rustc-link-lib=yuv"); - #[cfg(target_os = "linux")] - println!("cargo:rustc-link-lib=stdc++"); + if target_os == "linux" { + 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)"); } @@ -268,6 +529,7 @@ fn link_system() -> bool { if use_static && libyuv_static.exists() { println!("cargo:rustc-link-search=native={}", path); println!("cargo:rustc-link-lib=static=yuv"); + link_libjpeg_for_static_libyuv(&[lib_path.to_path_buf()], "linux"); println!("cargo:rustc-link-lib=stdc++"); println!( "cargo:info=Using system libyuv from {} (static linking)", @@ -294,3 +556,58 @@ fn link_system() -> bool { false } + +fn link_libjpeg_for_static_libyuv(preferred_lib_dirs: &[PathBuf], target_os: &str) { + if target_os != "linux" { + return; + } + + println!("cargo:rerun-if-env-changed=ONE_KVM_LIBJPEG_DIR"); + + let mut lib_dirs = Vec::new(); + if let Ok(path) = env::var("ONE_KVM_LIBJPEG_DIR") { + if !path.trim().is_empty() { + lib_dirs.push(PathBuf::from(path)); + } + } + lib_dirs.extend(preferred_lib_dirs.iter().cloned()); + lib_dirs.extend( + [ + "/usr/local/lib", + "/usr/local/lib64", + "/usr/lib", + "/usr/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/aarch64-linux-gnu", + "/usr/lib/arm-linux-gnueabihf", + "/usr/aarch64-linux-gnu/lib", + "/usr/arm-linux-gnueabihf/lib", + ] + .iter() + .map(PathBuf::from), + ); + + for lib_dir in dedupe_paths(lib_dirs) { + if lib_dir.join("libjpeg.a").exists() { + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-lib=static=jpeg"); + println!( + "cargo:info=Using libjpeg for static libyuv MJPEG from {}", + lib_dir.display() + ); + return; + } + } + + println!("cargo:warning=libjpeg.a not found; static libyuv built with MJPEG may fail to link"); +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut deduped = Vec::new(); + for path in paths { + if !deduped.iter().any(|existing| existing == &path) { + deduped.push(path); + } + } + deduped +} diff --git a/res/vcpkg/libyuv/cpp/yuv_ffi.h b/res/vcpkg/libyuv/cpp/yuv_ffi.h index 2f7199e7..003ed938 100644 --- a/res/vcpkg/libyuv/cpp/yuv_ffi.h +++ b/res/vcpkg/libyuv/cpp/yuv_ffi.h @@ -103,6 +103,13 @@ int NV21ToI420(const uint8_t* src_y, int src_stride_y, uint8_t* dst_v, int dst_stride_v, int width, int height); +// NV21 -> NV12 +int NV21ToNV12(const uint8_t* src_y, int src_stride_y, + const uint8_t* src_vu, int src_stride_vu, + uint8_t* dst_y, int dst_stride_y, + uint8_t* dst_uv, int dst_stride_uv, + int width, int height); + // Split interleaved UV plane into separate U and V planes void SplitUVPlane(const uint8_t* src_uv, int src_stride_uv, uint8_t* dst_u, int dst_stride_u, @@ -167,6 +174,12 @@ int RAWToI420(const uint8_t* src_raw, int src_stride_raw, uint8_t* dst_v, int dst_stride_v, int width, int height); +// BGR24 -> NV12 +int RGB24ToNV12(const uint8_t* src_rgb24, int src_stride_rgb24, + uint8_t* dst_y, int dst_stride_y, + uint8_t* dst_uv, int dst_stride_uv, + int width, int height); + // RGB24 -> ARGB int RGB24ToARGB(const uint8_t* src_rgb24, int src_stride_rgb24, uint8_t* dst_argb, int dst_stride_argb, @@ -253,12 +266,6 @@ int MJPGToNV12(const uint8_t* sample, size_t sample_size, int src_width, int src_height, int dst_width, int dst_height); -// MJPEG -> ARGB -int MJPGToARGB(const uint8_t* sample, size_t sample_size, - uint8_t* dst_argb, int dst_stride_argb, - int src_width, int src_height, - int dst_width, int dst_height); - // Get MJPEG dimensions without decoding int MJPGSize(const uint8_t* sample, size_t sample_size, int* width, int* height); diff --git a/res/vcpkg/libyuv/src/lib.rs b/res/vcpkg/libyuv/src/lib.rs index 15aaa851..896a2eab 100644 --- a/res/vcpkg/libyuv/src/lib.rs +++ b/res/vcpkg/libyuv/src/lib.rs @@ -32,17 +32,9 @@ use std::fmt; // Include auto-generated FFI bindings include!(concat!(env!("OUT_DIR"), "/yuv_ffi.rs")); -// Type alias for C's size_t - adapts to platform pointer width -#[cfg(target_pointer_width = "32")] -type SizeT = u32; - -#[cfg(target_pointer_width = "64")] -type SizeT = u64; - -// Helper function to convert usize to C's size_t type #[inline] -fn usize_to_size_t(val: usize) -> SizeT { - val as SizeT +fn usize_to_size_t(val: usize) -> usize { + val } // ============================================================================ @@ -522,6 +514,34 @@ pub fn nv21_to_i420(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Resu )) } +/// Convert NV21 to NV12 by swapping interleaved chroma bytes. +pub fn nv21_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> { + if width % 2 != 0 || height % 2 != 0 { + return Err(YuvError::InvalidDimensions); + } + + let w = width as usize; + let h = height as usize; + let y_size = w * h; + + if src.len() < nv12_size(w, h) || dst.len() < nv12_size(w, h) { + return Err(YuvError::BufferTooSmall); + } + + call_yuv!(NV21ToNV12( + src.as_ptr(), + width, + src[y_size..].as_ptr(), + width, + dst.as_mut_ptr(), + width, + dst[y_size..].as_mut_ptr(), + width, + width, + height, + )) +} + // ============================================================================ // ARGB/BGRA conversions (32-bit) // Note: libyuv ARGB = BGRA in memory on little-endian systems @@ -1046,7 +1066,7 @@ pub fn rgb24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Res i420_to_nv12(&i420_buffer, dst, width, height) } -/// Convert BGR24 to NV12 (via two-step conversion: BGR24 → I420 → NV12) +/// Convert BGR24 to NV12. pub fn bgr24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> { if width % 2 != 0 || height % 2 != 0 { return Err(YuvError::InvalidDimensions); @@ -1059,10 +1079,71 @@ pub fn bgr24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Res return Err(YuvError::BufferTooSmall); } - // Two-step conversion: BGR24 → I420 → NV12 - let mut i420_buffer = vec![0u8; i420_size(w, h)]; - bgr24_to_i420(src, &mut i420_buffer, width, height)?; - i420_to_nv12(&i420_buffer, dst, width, height) + #[cfg(windows)] + { + let mut i420_buffer = vec![0u8; i420_size(w, h)]; + bgr24_to_i420(src, &mut i420_buffer, width, height)?; + return i420_to_nv12(&i420_buffer, dst, width, height); + } + + #[cfg(not(windows))] + { + let y_size = w * h; + call_yuv!(RGB24ToNV12( + src.as_ptr(), + width * 3, + dst.as_mut_ptr(), + width, + dst[y_size..].as_mut_ptr(), + width, + width, + height, + )) + } +} + +/// Read MJPEG dimensions without decoding the frame. +pub fn mjpg_size(src: &[u8]) -> Result<(i32, i32)> { + let mut width = 0; + let mut height = 0; + + call_yuv!(MJPGSize( + src.as_ptr(), + usize_to_size_t(src.len()), + &mut width, + &mut height, + ))?; + + Ok((width, height)) +} + +/// Decode MJPEG directly to NV12. +pub fn mjpg_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> { + if width % 2 != 0 || height % 2 != 0 { + return Err(YuvError::InvalidDimensions); + } + + let w = width as usize; + let h = height as usize; + if dst.len() < nv12_size(w, h) { + return Err(YuvError::BufferTooSmall); + } + + let y_size = w * h; + let (dst_y, dst_uv) = dst.split_at_mut(y_size); + + call_yuv!(MJPGToNV12( + src.as_ptr(), + usize_to_size_t(src.len()), + dst_y.as_mut_ptr(), + width, + dst_uv.as_mut_ptr(), + width, + width, + height, + width, + height, + )) } // ============================================================================ diff --git a/scripts/build-android-alsa.sh b/scripts/build-android-alsa.sh new file mode 100644 index 00000000..cfdd924c --- /dev/null +++ b/scripts/build-android-alsa.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +SOURCE_DIR="" +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_REPO="${ALSA_REPO:-https://github.com/alsa-project/alsa-lib.git}" + +usage() { + cat <<'EOF' +Usage: + scripts/build-android-alsa.sh [options] + +Options: + --source Existing alsa-lib source checkout. If omitted, the + script clones it into .tmp/android-alsa-src. + --output Output root. Default: dist/android-alsa + --ndk Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT. + --api Android API level. Default: 21. + --abis 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: + /arm64-v8a/include/alsa/asoundlib.h + /arm64-v8a/lib/libasound.so + /arm64-v8a/lib/pkgconfig/alsa.pc + /armeabi-v7a/include/alsa/asoundlib.h + /armeabi-v7a/lib/libasound.so + /armeabi-v7a/lib/pkgconfig/alsa.pc +EOF +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + --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" + +if [[ -z "$SOURCE_DIR" ]]; then + SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-alsa-src" + if [[ ! -d "$SOURCE_DIR/.git" ]]; then + rm -rf "$SOURCE_DIR" + git clone --depth 1 "$ALSA_REPO" "$SOURCE_DIR" + fi +fi + +[[ -d "$SOURCE_DIR" ]] || fail "alsa-lib source not found: $SOURCE_DIR" +[[ -f "$SOURCE_DIR/configure.ac" || -f "$SOURCE_DIR/configure" ]] || fail "alsa-lib source layout not recognized under: $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}" +ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake" +[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN" +[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE" +command -v cmake >/dev/null 2>&1 || fail "cmake is required" +command -v autoreconf >/dev/null 2>&1 || fail "autoreconf is required" + +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 [[ -f "$SOURCE_DIR/config.status" || -f "$SOURCE_DIR/Makefile" ]]; then + ( + cd "$SOURCE_DIR" + make distclean >/dev/null 2>&1 || true + ) + clean_generated_source_headers + fi + + 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-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" < [options] + +Required: + --source FFmpeg source directory. For the downloaded package, + use the extracted ffmpeg-rockchip directory. + +Options: + --output Output root. Default: dist/android-ffmpeg-mediacodec + --ndk Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT. + --api Android API level. Default: 21. + --abis 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: + /arm64-v8a/include + /arm64-v8a/lib + /armeabi-v7a/include + /armeabi-v7a/lib + +Example: + scripts/build-android-ffmpeg-mediacodec.sh \ + --source .tmp/android-ffmpeg-check/src/ffmpeg-rockchip \ + --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 + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + --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 "$SOURCE_DIR" ]] || fail "--source is required" +[[ -d "$SOURCE_DIR" ]] || fail "FFmpeg source not found: $SOURCE_DIR" +[[ -x "$SOURCE_DIR/configure" ]] || fail "FFmpeg configure script not found under: $SOURCE_DIR" +[[ -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="$(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}" +[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN" + +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" + + [[ -f "$mediacodecdec" ]] || fail "FFmpeg mediacodecdec.c not found: $mediacodecdec" + [[ -f "$allcodecs" ]] || fail "FFmpeg allcodecs.c not found: $allcodecs" + [[ -f "$makefile" ]] || fail "FFmpeg libavcodec Makefile not found: $makefile" + [[ -f "$configure_file" ]] || fail "FFmpeg configure not found: $configure_file" + + 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="" + + [[ -x "$cc" ]] || fail "Missing compiler: $cc" + + 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 < Existing libyuv source checkout. If omitted, the script + clones libyuv into .tmp/android-libyuv-src. + --output Output root. Default: dist/android-libyuv + --ndk Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT. + --api Android API level. Default: 21. + --abis Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a. + --jpeg-root 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: + /arm64-v8a/include + /arm64-v8a/lib/libyuv.a + /armeabi-v7a/include + /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 + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + --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" + +if [[ -z "$SOURCE_DIR" ]]; then + SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-libyuv-src" + if [[ ! -d "$SOURCE_DIR/.git" ]]; then + rm -rf "$SOURCE_DIR" + git clone --depth 1 "$LIBYUV_REPO" "$SOURCE_DIR" + fi +fi + +[[ -d "$SOURCE_DIR" ]] || fail "libyuv source not found: $SOURCE_DIR" +[[ -f "$SOURCE_DIR/CMakeLists.txt" ]] || fail "libyuv CMakeLists.txt not found under: $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}" +ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake" +[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN" +[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE" +command -v cmake >/dev/null 2>&1 || fail "cmake is required" + +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 < Existing opus source checkout. If omitted, the script + downloads and extracts the official source tarball + into .tmp/android-opus-src. + --output Output root. Default: dist/android-opus + --ndk Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT. + --api Android API level. Default: 21. + --abis 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: + /arm64-v8a/include/opus/opus.h + /arm64-v8a/lib/libopus.so + /armeabi-v7a/include/opus/opus.h + /armeabi-v7a/lib/libopus.so +EOF +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + --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" + +if [[ -z "$SOURCE_DIR" ]]; then + SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-opus-src" + if [[ ! -f "$SOURCE_DIR/configure" ]]; then + rm -rf "$SOURCE_DIR" + mkdir -p "$SOURCE_DIR" + tarball="${PROJECT_ROOT}/.tmp/opus-${OPUS_VERSION}.tar.gz" + if [[ -f "$LOCAL_OPUS_TARBALL" ]]; then + cp "$LOCAL_OPUS_TARBALL" "$tarball" + else + command -v curl >/dev/null 2>&1 || fail "curl is required to download opus source" + curl -fsSL "$OPUS_TARBALL_URL" -o "$tarball" + fi + echo "${OPUS_TARBALL_SHA256} ${tarball}" | sha256sum -c - + tar -xzf "$tarball" -C "$SOURCE_DIR" --strip-components=1 + fi +fi + +[[ -d "$SOURCE_DIR" ]] || fail "opus source not found: $SOURCE_DIR" +[[ -x "$SOURCE_DIR/configure" || -f "$SOURCE_DIR/configure.ac" ]] || fail "opus source layout not recognized under: $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}" +ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake" +[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN" +[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE" +command -v cmake >/dev/null 2>&1 || fail "cmake is required" + +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 + ) + + mkdir -p "$prefix/lib" "$prefix/include" + if [[ -f "$prefix/include/opus/opus.h" ]]; then + : + elif [[ -f "$SOURCE_DIR/include/opus/opus.h" ]]; then + mkdir -p "$prefix/include/opus" + cp "$SOURCE_DIR/include/opus/opus.h" "$prefix/include/opus/opus.h" + fi + + echo "Built Opus for ${abi}: ${prefix}" +} + +for abi in $(normalize_abis); do + build_one "$abi" +done + +cat < Existing libjpeg-turbo source checkout. If omitted, + the script clones it into .tmp/android-turbojpeg-src. + --output Output root. Default: dist/android-turbojpeg + --ndk Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT. + --api Android API level. Default: 21. + --abis 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: + /arm64-v8a/include/turbojpeg.h + /arm64-v8a/lib/libturbojpeg.a + /arm64-v8a/include/jpeglib.h + /arm64-v8a/lib/libjpeg.a + /armeabi-v7a/include/turbojpeg.h + /armeabi-v7a/lib/libturbojpeg.a + /armeabi-v7a/include/jpeglib.h + /armeabi-v7a/lib/libjpeg.a +EOF +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) + SOURCE_DIR="${2:-}" + shift 2 + ;; + --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" + +if [[ -z "$SOURCE_DIR" ]]; then + SOURCE_DIR="${PROJECT_ROOT}/.tmp/android-turbojpeg-src" + if [[ ! -d "$SOURCE_DIR/.git" ]]; then + rm -rf "$SOURCE_DIR" + git clone --depth 1 "$LIBJPEG_TURBO_REPO" "$SOURCE_DIR" + fi +fi + +[[ -d "$SOURCE_DIR" ]] || fail "libjpeg-turbo source not found: $SOURCE_DIR" +[[ -f "$SOURCE_DIR/CMakeLists.txt" ]] || fail "libjpeg-turbo CMakeLists.txt not found under: $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}" +ANDROID_TOOLCHAIN_FILE="${NDK_ROOT}/build/cmake/android.toolchain.cmake" +[[ -d "$TOOLCHAIN/bin" ]] || fail "NDK LLVM toolchain not found: $TOOLCHAIN" +[[ -f "$ANDROID_TOOLCHAIN_FILE" ]] || fail "NDK CMake toolchain not found: $ANDROID_TOOLCHAIN_FILE" +command -v cmake >/dev/null 2>&1 || fail "cmake is required" + +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 < { @@ -102,13 +100,22 @@ impl AtxController { } } else { for (slot, warn_label, info_label, config) in [ - (&mut inner.power_executor, "power", "Power", inner.config.power.clone()), - (&mut inner.reset_executor, "reset", "Reset", inner.config.reset.clone()), + ( + &mut inner.power_executor, + "power", + "Power", + inner.config.power.clone(), + ), + ( + &mut inner.reset_executor, + "reset", + "Reset", + inner.config.reset.clone(), + ), ] { if config.is_configured() { let executor = AtxKeyExecutor::new(config.clone()); - *slot = Self::init_key_executor(warn_label, info_label, config, executor) - .await; + *slot = Self::init_key_executor(warn_label, info_label, config, executor).await; } } } @@ -229,11 +236,13 @@ impl AtxController { }; let Some(executor) = executor else { - return Err(AppError::Config(match action { - AtxAction::Reset => "Reset button not configured for ATX controller", - _ => "Power button not configured for ATX controller", - } - .to_string())); + return Err(AppError::Config( + match action { + AtxAction::Reset => "Reset button not configured for ATX controller", + _ => "Power button not configured for ATX controller", + } + .to_string(), + )); }; executor.pulse(duration).await?; diff --git a/src/atx/hidraw_linux.rs b/src/atx/hidraw_linux.rs index 829a93dd..b95f357a 100644 --- a/src/atx/hidraw_linux.rs +++ b/src/atx/hidraw_linux.rs @@ -102,7 +102,7 @@ impl HidrawLinuxRelayBackend { device: &File, report: &[u8; USB_RELAY_REPORT_LEN], ) -> std::io::Result<()> { - let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9, report.as_ptr()) }; + let rc = unsafe { libc::ioctl(device.as_raw_fd(), HIDIOCSFEATURE_9 as _, report.as_ptr()) }; if rc < 0 { Err(std::io::Error::last_os_error()) } else { diff --git a/src/atx/mod.rs b/src/atx/mod.rs index 67429216..a4124abf 100644 --- a/src/atx/mod.rs +++ b/src/atx/mod.rs @@ -65,9 +65,12 @@ pub fn discover_devices() -> AtxDevices { let name_str = name.to_string_lossy(); if name_str.starts_with("gpiochip") { devices.gpio_chips.push(format!("/dev/{}", name_str)); - } else if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) { + } + #[cfg(unix)] + if name_str.starts_with("hidraw") && is_usb_relay_hidraw(&name_str) { devices.usb_relays.push(format!("/dev/{}", name_str)); - } else if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") { + } + if name_str.starts_with("ttyUSB") || name_str.starts_with("ttyACM") { devices.serial_ports.push(format!("/dev/{}", name_str)); } } diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 8565763d..14f5a9d7 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -1,7 +1,11 @@ -#[cfg(unix)] +#[cfg(all(unix, not(feature = "android")))] #[path = "capture_linux.rs"] mod imp; +#[cfg(feature = "android")] +#[path = "capture_android.rs"] +mod imp; + #[cfg(windows)] #[path = "capture_windows.rs"] mod imp; diff --git a/src/audio/capture_android.rs b/src/audio/capture_android.rs new file mode 100644 index 00000000..f68b3021 --- /dev/null +++ b/src/audio/capture_android.rs @@ -0,0 +1,292 @@ +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>, + state_rx: watch::Receiver, + frame_tx: broadcast::Sender, + stop_flag: Arc, + sequence: Arc, + capture_handle: Mutex>>, + 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 { + self.state_rx.clone() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + 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, + frame_tx: &broadcast::Sender, + 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(()) +} diff --git a/src/audio/controller.rs b/src/audio/controller.rs index 84742fa2..2c0e064c 100644 --- a/src/audio/controller.rs +++ b/src/audio/controller.rs @@ -6,11 +6,13 @@ use tokio::sync::RwLock; use tracing::{debug, info}; use super::capture::AudioConfig; -use super::device::{enumerate_audio_devices_with_current, find_best_audio_device, AudioDeviceInfo}; +use super::device::{ + enumerate_audio_devices_with_current, find_best_audio_device, AudioDeviceInfo, +}; use super::encoder::OpusFrame; use super::monitor::AudioHealthMonitor; -use super::streamer::{AudioStreamer, AudioStreamerConfig}; use super::recovery; +use super::streamer::{AudioStreamer, AudioStreamerConfig}; use super::types::{AudioControllerConfig, AudioQuality, AudioStatus}; use crate::error::{AppError, Result}; use crate::events::EventBus; diff --git a/src/audio/device.rs b/src/audio/device.rs index e8752ac2..e19c8a74 100644 --- a/src/audio/device.rs +++ b/src/audio/device.rs @@ -1,7 +1,11 @@ -#[cfg(unix)] +#[cfg(all(unix, not(feature = "android")))] #[path = "device_linux.rs"] mod imp; +#[cfg(feature = "android")] +#[path = "device_android.rs"] +mod imp; + #[cfg(windows)] #[path = "device_windows.rs"] mod imp; diff --git a/src/audio/device_android.rs b/src/audio/device_android.rs new file mode 100644 index 00000000..dc20514e --- /dev/null +++ b/src/audio/device_android.rs @@ -0,0 +1,185 @@ +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, + pub channels: Vec, + pub is_capture: bool, + pub is_hdmi: bool, + pub usb_bus: Option, +} + +fn get_usb_bus_info(card_index: i32) -> Option { + 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> { + enumerate_audio_devices_with_current(None) +} + +pub fn enumerate_audio_devices_with_current( + current_device: Option<&str>, +) -> Result> { + 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, channels: Vec, 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, Vec) { + 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 { + 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) +} diff --git a/src/audio/recovery.rs b/src/audio/recovery.rs index d22f90fa..99c0b16a 100644 --- a/src/audio/recovery.rs +++ b/src/audio/recovery.rs @@ -4,11 +4,11 @@ use tokio::sync::RwLock; use tracing::{debug, info, warn}; use super::capture::AudioConfig; +use super::controller::AudioRecoveredCallback; use super::device::{enumerate_audio_devices, AudioDeviceInfo}; use super::monitor::AudioHealthMonitor; use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig}; use super::types::AudioControllerConfig; -use super::controller::AudioRecoveredCallback; use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent}; const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1); diff --git a/src/config/schema/atx.rs b/src/config/schema/atx.rs index d8913393..e7c7c5f1 100644 --- a/src/config/schema/atx.rs +++ b/src/config/schema/atx.rs @@ -25,4 +25,3 @@ impl AtxConfig { } } } - diff --git a/src/config/schema/common.rs b/src/config/schema/common.rs index 2ce3cc2a..662cb9de 100644 --- a/src/config/schema/common.rs +++ b/src/config/schema/common.rs @@ -61,4 +61,3 @@ impl std::fmt::Display for BitratePreset { } } } - diff --git a/src/config/schema/hid.rs b/src/config/schema/hid.rs index 7dae19e6..8e852492 100644 --- a/src/config/schema/hid.rs +++ b/src/config/schema/hid.rs @@ -306,4 +306,3 @@ impl HidConfig { } } } - diff --git a/src/config/schema/mod.rs b/src/config/schema/mod.rs index 52b013b6..1030de9a 100644 --- a/src/config/schema/mod.rs +++ b/src/config/schema/mod.rs @@ -41,4 +41,3 @@ impl AppConfig { crate::platform::defaults::apply(self); } } - diff --git a/src/config/schema/stream.rs b/src/config/schema/stream.rs index aefec097..d201d829 100644 --- a/src/config/schema/stream.rs +++ b/src/config/schema/stream.rs @@ -146,4 +146,3 @@ impl Default for RedfishConfig { Self { enabled: false } } } - diff --git a/src/diagnostics/linux.rs b/src/diagnostics/linux.rs index 29403ae1..d703a8b8 100644 --- a/src/diagnostics/linux.rs +++ b/src/diagnostics/linux.rs @@ -176,6 +176,19 @@ fn get_meminfo() -> MemInfo { } fn get_network_addresses() -> Vec { + #[cfg(target_os = "android")] + { + 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 { let all_addrs = match nix::ifaddrs::getifaddrs() { Ok(addrs) => addrs, Err(_) => return Vec::new(), @@ -247,6 +260,101 @@ fn get_network_addresses() -> Vec { addresses } +#[cfg(target_os = "android")] +fn get_network_addresses_android() -> Vec { + 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 { + 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::()]; + std::ptr::copy_nonoverlapping( + &sockaddr as *const libc::sockaddr as *const u8, + storage.as_mut_ptr(), + size_of::(), + ); + 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)] mod tests { use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes}; diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index 7641a2f0..e42fd7bc 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -36,7 +36,6 @@ const RECONNECT_DELAY_MS: u64 = 2000; const INIT_WAIT_MS: u64 = 3000; - struct Ch9329RuntimeState { initialized: AtomicBool, online: AtomicBool, @@ -843,8 +842,8 @@ impl HidBackend for Ch9329Backend { #[cfg(test)] mod tests { - use super::*; use super::ch9329_proto::{build_packet, calculate_checksum}; + use super::*; #[test] fn test_packet_building() { diff --git a/src/hid/ch9329_proto.rs b/src/hid/ch9329_proto.rs index 1b0b27d8..ef1bb0cb 100644 --- a/src/hid/ch9329_proto.rs +++ b/src/hid/ch9329_proto.rs @@ -167,7 +167,10 @@ pub fn calculate_checksum(data: &[u8]) -> u8 { #[inline] pub fn build_packet_buf(address: u8, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) { - debug_assert!(data.len() <= MAX_DATA_LEN, "Data too long for CH9329 packet"); + debug_assert!( + data.len() <= MAX_DATA_LEN, + "Data too long for CH9329 packet" + ); let len = data.len() as u8; let packet_len = 6 + data.len(); diff --git a/src/hid/mod.rs b/src/hid/mod.rs index 8e30ff30..ab25237a 100644 --- a/src/hid/mod.rs +++ b/src/hid/mod.rs @@ -1,8 +1,8 @@ //! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329. pub mod backend; -mod ch9329_proto; pub mod ch9329; +mod ch9329_proto; pub mod consumer; pub mod datachannel; mod factory; diff --git a/src/hid/otg.rs b/src/hid/otg.rs index e3b9e5e8..83b5ef8b 100644 --- a/src/hid/otg.rs +++ b/src/hid/otg.rs @@ -4,6 +4,7 @@ //! Polled timed writes (JetKVM-style). Treat `ESHUTDOWN` (108) by closing handles and reopening; keep fd on `EAGAIN` (11). Host/gadget teardown during MSD resembles PiKVM. use async_trait::async_trait; +use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use parking_lot::Mutex; use std::fs::{self, File, OpenOptions}; use std::io::Read; @@ -14,7 +15,6 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::Arc; use std::thread; use std::time::Duration; -use nix::poll::{poll, PollFd, PollFlags, PollTimeout}; use tokio::sync::watch; use tracing::{debug, info, trace, warn}; @@ -222,15 +222,7 @@ impl OtgBackend { } fn find_udc() -> Option { - let udc_path = PathBuf::from("/sys/class/udc"); - if let Ok(entries) = fs::read_dir(&udc_path) { - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - return Some(name.to_string()); - } - } - } - None + crate::otg::configfs::find_udc() } /// PiKVM-style: drop handle if node missing; reopen when path reappears. diff --git a/src/lib.rs b/src/lib.rs index 460771ae..6ab92e95 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,37 +1,64 @@ //! Core library for One-KVM (IP‑KVM: capture, HID, OTG, streaming, Web UI glue). -#[cfg(not(any(unix, windows)))] +#[cfg(not(any(feature = "android", unix, windows)))] compile_error!("One-KVM supports Linux and Windows targets only."); +#[cfg(any(feature = "android", feature = "desktop"))] +pub mod runtime; + +#[cfg(any(feature = "android", feature = "desktop"))] pub mod atx; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod audio; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod auth; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod config; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod db; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod diagnostics; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod error; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod events; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod extensions; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod hid; -#[cfg(unix)] +#[cfg(all(unix, any(feature = "android", feature = "desktop")))] pub mod msd; -#[cfg(unix)] +#[cfg(all(unix, any(feature = "android", feature = "desktop")))] pub mod otg; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod platform; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod redfish; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod rtsp; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod rustdesk; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod state; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod stream; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod stream_encoder; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod update; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod utils; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod video; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod web; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod webrtc; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod secrets { include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs")); } +#[cfg(any(feature = "android", feature = "desktop"))] pub use error::{AppError, Result}; diff --git a/src/otg/configfs.rs b/src/otg/configfs.rs index 8efdf156..58ae0d4b 100644 --- a/src/otg/configfs.rs +++ b/src/otg/configfs.rs @@ -49,16 +49,26 @@ pub fn ensure_libcomposite_loaded() -> Result<()> { } pub fn find_udc() -> Option { - let udc_path = Path::new("/sys/class/udc"); - if !udc_path.exists() { - return None; - } + list_udcs().into_iter().next() +} - fs::read_dir(udc_path) - .ok()? - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().to_string()) - .next() +pub fn list_udcs() -> Vec { + let mut devices = Vec::new(); + collect_dir_names(Path::new("/sys/class/udc"), &mut devices); + devices.sort(); + devices.dedup(); + devices +} + +fn collect_dir_names(path: &Path, devices: &mut Vec) { + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().trim().to_string(); + if !name.is_empty() { + devices.push(name); + } + } + } } pub fn is_low_endpoint_udc(name: &str) -> bool { diff --git a/src/otg/mod.rs b/src/otg/mod.rs index 4d0deed8..5c8b9d17 100644 --- a/src/otg/mod.rs +++ b/src/otg/mod.rs @@ -25,13 +25,12 @@ pub use service::{HidDevicePaths, OtgService}; /// List USB Device Controller names exposed by sysfs. pub fn list_udc_devices() -> Vec { - let mut devices: Vec = std::fs::read_dir("/sys/class/udc") - .ok() - .into_iter() - .flat_map(|entries| entries.filter_map(|entry| entry.ok())) - .filter_map(|entry| entry.file_name().to_str().map(str::to_owned)) - .collect(); - - devices.sort(); - devices + #[cfg(unix)] + { + configfs::list_udcs() + } + #[cfg(not(unix))] + { + Vec::new() + } } diff --git a/src/platform/android.rs b/src/platform/android.rs new file mode 100644 index 00000000..03f0f1cc --- /dev/null +++ b/src/platform/android.rs @@ -0,0 +1,43 @@ +//! 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"]), + diagnostics: FeatureCapability::available(["android_linux"]), + extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"), + service_installation: FeatureCapability::available(["android_foreground_service"]), + } +} diff --git a/src/platform/android_bionic.rs b/src/platform/android_bionic.rs new file mode 100644 index 00000000..6dde13b5 --- /dev/null +++ b/src/platform/android_bionic.rs @@ -0,0 +1,175 @@ +#![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 { + 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 { + 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::(), + ); + 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::(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; + } +} diff --git a/src/platform/capabilities.rs b/src/platform/capabilities.rs index 494f77bb..1308fec4 100644 --- a/src/platform/capabilities.rs +++ b/src/platform/capabilities.rs @@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PlatformMode { + AndroidAmlogic, Linux, Windows, } impl PlatformMode { pub const fn current() -> Self { - if cfg!(windows) { + if cfg!(feature = "android") { + Self::AndroidAmlogic + } else if cfg!(windows) { Self::Windows } else { Self::Linux @@ -20,6 +23,7 @@ impl PlatformMode { pub const fn label(self) -> &'static str { match self { + Self::AndroidAmlogic => "Android Amlogic", Self::Linux => "Linux", Self::Windows => "Windows", } @@ -81,9 +85,17 @@ pub struct PlatformCapabilities { impl PlatformCapabilities { pub fn current() -> Self { - match PlatformMode::current() { - PlatformMode::Linux => crate::platform::linux::capabilities(), - PlatformMode::Windows => crate::platform::windows::capabilities(), + #[cfg(feature = "android")] + { + return crate::platform::android::capabilities(); + } + #[cfg(windows)] + { + return crate::platform::windows::capabilities(); + } + #[cfg(all(unix, not(feature = "android")))] + { + return crate::platform::linux::capabilities(); } } } diff --git a/src/platform/defaults.rs b/src/platform/defaults.rs index 90b77230..0741b691 100644 --- a/src/platform/defaults.rs +++ b/src/platform/defaults.rs @@ -1,11 +1,73 @@ -use crate::config::{AppConfig, AtxDriverType, HidBackend}; +use crate::config::AppConfig; +#[cfg(windows)] +use crate::config::AtxDriverType; +#[cfg(any(windows, all(unix, feature = "android")))] +use crate::config::HidBackend; pub fn apply(config: &mut AppConfig) { - if cfg!(windows) { + #[cfg(not(any(windows, all(unix, feature = "android"))))] + { + let _ = config; + } + + #[cfg(all(unix, feature = "android"))] + { + apply_android(config); + } + + #[cfg(windows)] + { 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)] fn apply_windows(config: &mut AppConfig) { config.msd.enabled = false; config.hid.otg_udc = None; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 06c876d7..8794c260 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,10 +1,16 @@ //! Platform selection and capability reporting. +#[cfg(feature = "android")] +pub mod android; +#[cfg(feature = "android")] +pub mod android_bionic; pub mod capabilities; pub mod defaults; +#[cfg(target_os = "linux")] pub mod linux; #[cfg(unix)] pub mod usb_reset; +#[cfg(windows)] pub mod windows; pub use capabilities::{FeatureCapability, PlatformCapabilities, PlatformMode}; diff --git a/src/redfish/routes/mod.rs b/src/redfish/routes/mod.rs index f50a73c0..22006637 100644 --- a/src/redfish/routes/mod.rs +++ b/src/redfish/routes/mod.rs @@ -4,7 +4,7 @@ mod event; mod managers; mod session; mod systems; -#[cfg(unix)] +#[cfg(all(unix, not(feature = "android")))] mod virtual_media; use axum::{ @@ -200,7 +200,7 @@ pub fn create_redfish_router(state: Arc) -> Router { redfish_auth_middleware, )); - #[cfg(unix)] + #[cfg(all(unix, not(feature = "android")))] let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone())); Router::new() diff --git a/src/runtime/android.rs b/src/runtime/android.rs new file mode 100644 index 00000000..d0bc60ba --- /dev/null +++ b/src/runtime/android.rs @@ -0,0 +1,735 @@ +//! 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::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; +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, StreamCodecConstraints, +}; +use crate::video::format::{PixelFormat, Resolution}; +use crate::video::{Streamer, VideoStreamManager}; +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>> = OnceLock::new(); + +fn handle_slot() -> &'static Mutex> { + HANDLE.get_or_init(|| Mutex::new(None)) +} + +pub fn start(config: AndroidRuntimeConfig) -> Result { + 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::<()>(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 = async move { + let _ = stop_rx.await; + tracing::info!("Android stop request received"); + let _ = shutdown_tx.send(()); + }; + + 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<()>, +) -> Result, 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, + }, + 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::() + .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, + ); + 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 rustdesk = if config.rustdesk.is_valid() { + Some(Arc::new(RustDeskService::new( + config.rustdesk.clone(), + stream_manager.clone(), + hid.clone(), + audio.clone(), + ))) + } else { + None + }; + + let rtsp = if config.rtsp.enabled { + Some(Arc::new(RtspService::new( + config.rtsp.clone(), + stream_manager.clone(), + ))) + } else { + None + }; + + let update_service = Arc::new(UpdateService::new(data_dir.join("updates"))); + let state = AppState::new( + db, + config_store.clone(), + session_store, + user_store, + otg_service, + stream_manager, + webrtc_streamer, + hid, + msd, + atx, + audio, + rustdesk.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) = 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::().ok()) + .unwrap_or(PixelFormat::Mjpeg); + ( + format, + Resolution::new(config.video.width, config.video.height), + ) +} + +fn bind_android_listener(bind_address: &str, port: u16) -> Result { + let ip = bind_address + .parse::() + .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, events: Arc) { + 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) { + 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.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()); + }); +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 00000000..3ddf992d --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,4 @@ +//! Runtime entry points for packaged service modes. + +#[cfg(feature = "android")] +pub mod android; diff --git a/src/stream/mjpeg.rs b/src/stream/mjpeg.rs index d0a633f3..108d4d95 100644 --- a/src/stream/mjpeg.rs +++ b/src/stream/mjpeg.rs @@ -1,4 +1,5 @@ use arc_swap::ArcSwap; +#[cfg(feature = "desktop")] use parking_lot::Mutex as ParkingMutex; use parking_lot::RwLock as ParkingRwLock; use std::collections::{HashMap, VecDeque}; @@ -6,13 +7,18 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::broadcast; -use tracing::{debug, info, warn}; +#[cfg(feature = "desktop")] +use tracing::debug; +use tracing::{info, warn}; /// Generation token paired with `client_id` so [`unregister_client`] ignores stale drops. pub type ClientGeneration = u64; +#[cfg(feature = "desktop")] use crate::video::codec::traits::{Encoder, EncoderConfig}; +#[cfg(feature = "desktop")] use crate::video::codec::JpegEncoder; +#[cfg(feature = "desktop")] use crate::video::format::PixelFormat; use crate::video::VideoFrame; @@ -108,6 +114,7 @@ pub struct MjpegStreamHandler { last_frame_ts: ParkingRwLock>, dropped_same_frames: AtomicU64, max_drop_same_frames: AtomicU64, + #[cfg(feature = "desktop")] jpeg_encoder: ParkingMutex>, jpeg_quality: AtomicU64, } @@ -126,6 +133,7 @@ impl MjpegStreamHandler { sequence: AtomicU64::new(0), clients: ParkingRwLock::new(HashMap::new()), next_generation: AtomicU64::new(1), + #[cfg(feature = "desktop")] jpeg_encoder: ParkingMutex::new(None), auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()), last_frame_ts: ParkingRwLock::new(None), @@ -157,6 +165,7 @@ impl MjpegStreamHandler { } let frame = if !frame.format.is_compressed() { + #[cfg(feature = "desktop")] match self.encode_to_jpeg(&frame) { Ok(jpeg_frame) => jpeg_frame, Err(e) => { @@ -164,6 +173,13 @@ impl MjpegStreamHandler { return; } } + #[cfg(not(feature = "desktop"))] + { + warn!( + "Dropping non-JPEG frame for MJPEG stream on Android; native encoder is not wired yet" + ); + return; + } } else { frame }; @@ -200,6 +216,7 @@ impl MjpegStreamHandler { let _ = self.frame_notify.send(()); } + #[cfg(feature = "desktop")] fn encode_to_jpeg(&self, frame: &VideoFrame) -> Result { let resolution = frame.resolution; let sequence = self.sequence.load(Ordering::Relaxed); diff --git a/src/stream/mod.rs b/src/stream/mod.rs index 3c5e2561..b1413490 100644 --- a/src/stream/mod.rs +++ b/src/stream/mod.rs @@ -1,7 +1,9 @@ //! MJPEG multipart streaming and WebSocket HID (for MJPEG mode). pub mod mjpeg; +#[cfg(feature = "desktop")] pub mod ws_hid; pub use mjpeg::{ClientGuard, MjpegStreamHandler}; +#[cfg(feature = "desktop")] pub use ws_hid::WsHidHandler; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index f38bd456..380ccb7f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,9 +2,9 @@ pub mod fs; pub mod host; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "android")))] pub mod net; -#[cfg(not(unix))] +#[cfg(any(not(unix), target_os = "android"))] #[path = "net_disabled.rs"] pub mod net; pub mod serial; diff --git a/src/video/codec/android_mediacodec.rs b/src/video/codec/android_mediacodec.rs new file mode 100644 index 00000000..4aa3aa5d --- /dev/null +++ b/src/video/codec/android_mediacodec.rs @@ -0,0 +1,122 @@ +//! 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 { + 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> { + 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, +} diff --git a/src/video/codec/android_mjpeg.rs b/src/video/codec/android_mjpeg.rs new file mode 100644 index 00000000..d7d6b04a --- /dev/null +++ b/src/video/codec/android_mjpeg.rs @@ -0,0 +1,137 @@ +//! 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, + last_output_format: Option, + pending_frames: u32, +} + +impl AndroidMediaCodecMjpegDecoder { + pub fn new(resolution: Resolution) -> Result { + 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> { + 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 { + 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 {} diff --git a/src/video/codec/convert.rs b/src/video/codec/convert.rs index bde4e5f9..f362f064 100644 --- a/src/video/codec/convert.rs +++ b/src/video/codec/convert.rs @@ -539,8 +539,46 @@ pub struct Nv12Converter { resolution: Resolution, /// Output buffer (reused across conversions) output_buffer: Nv12Buffer, - /// Optional I420 buffer for intermediate conversions - i420_buffer: Option, +} + +/// MJPEG decoder that writes NV12 directly using libyuv. +pub struct MjpegToNv12Decoder { + resolution: Resolution, + output_buffer: Nv12Buffer, + size_checked: bool, +} + +impl MjpegToNv12Decoder { + pub fn new(resolution: Resolution) -> Self { + Self { + resolution, + output_buffer: Nv12Buffer::new(resolution), + size_checked: false, + } + } + + pub fn decode(&mut self, input: &[u8]) -> Result<&[u8]> { + let width = self.resolution.width as i32; + let height = self.resolution.height as i32; + + if !self.size_checked { + let (src_width, src_height) = libyuv::mjpg_size(input).map_err(|e| { + AppError::VideoError(format!("libyuv MJPEG header read failed: {}", e)) + })?; + if src_width != width || src_height != height { + return Err(AppError::VideoError(format!( + "libyuv MJPEG size mismatch: {}x{} (expected {}x{})", + src_width, src_height, width, height + ))); + } + self.size_checked = true; + } + + libyuv::mjpg_to_nv12(input, self.output_buffer.as_bytes_mut(), width, height) + .map_err(|e| AppError::VideoError(format!("libyuv MJPEG->NV12 failed: {}", e)))?; + + Ok(self.output_buffer.as_bytes()) + } } impl Nv12Converter { @@ -550,7 +588,6 @@ impl Nv12Converter { src_format: PixelFormat::Bgr24, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -560,7 +597,6 @@ impl Nv12Converter { src_format: PixelFormat::Rgb24, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -570,7 +606,6 @@ impl Nv12Converter { src_format: PixelFormat::Yuyv, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -580,7 +615,6 @@ impl Nv12Converter { src_format: PixelFormat::Yuv420, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -590,7 +624,6 @@ impl Nv12Converter { src_format: PixelFormat::Nv21, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: Some(Yuv420pBuffer::new(resolution)), } } @@ -600,7 +633,6 @@ impl Nv12Converter { src_format: PixelFormat::Nv16, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -610,7 +642,6 @@ impl Nv12Converter { src_format: PixelFormat::Nv24, resolution, output_buffer: Nv12Buffer::new(resolution), - i420_buffer: None, } } @@ -621,23 +652,6 @@ impl Nv12Converter { // Handle formats that need custom conversion without holding dst borrow match self.src_format { - PixelFormat::Nv21 => { - let mut i420 = self.i420_buffer.take().ok_or_else(|| { - AppError::VideoError("NV21 I420 buffer not initialized".to_string()) - })?; - { - let dst = self.output_buffer.as_bytes_mut(); - Self::convert_nv21_to_nv12_with_dims( - self.resolution.width as usize, - self.resolution.height as usize, - input, - dst, - &mut i420, - )?; - } - self.i420_buffer = Some(i420); - return Ok(self.output_buffer.as_bytes()); - } PixelFormat::Nv16 => { let dst = self.output_buffer.as_bytes_mut(); Self::convert_nv16_to_nv12_with_dims( @@ -667,6 +681,7 @@ impl Nv12Converter { PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height), PixelFormat::Yuyv => libyuv::yuy2_to_nv12(input, dst, width, height), PixelFormat::Yuv420 => libyuv::i420_to_nv12(input, dst, width, height), + PixelFormat::Nv21 => libyuv::nv21_to_nv12(input, dst, width, height), _ => { return Err(AppError::VideoError(format!( "Unsupported conversion to NV12: {}", @@ -680,21 +695,6 @@ impl Nv12Converter { Ok(self.output_buffer.as_bytes()) } - fn convert_nv21_to_nv12_with_dims( - width: usize, - height: usize, - input: &[u8], - dst: &mut [u8], - yuv: &mut Yuv420pBuffer, - ) -> Result<()> { - libyuv::nv21_to_i420(input, yuv.as_bytes_mut(), width as i32, height as i32) - .map_err(|e| AppError::VideoError(format!("libyuv NV21->I420 failed: {}", e)))?; - libyuv::i420_to_nv12(yuv.as_bytes(), dst, width as i32, height as i32) - .map_err(|e| AppError::VideoError(format!("libyuv I420->NV12 failed: {}", e)))?; - - Ok(()) - } - fn convert_nv16_to_nv12_with_dims( width: usize, height: usize, diff --git a/src/video/codec/h264.rs b/src/video/codec/h264.rs index a70584c6..b08b6b74 100644 --- a/src/video/codec/h264.rs +++ b/src/video/codec/h264.rs @@ -48,6 +48,8 @@ pub enum H264EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) - requires hwcodec extension V4l2M2m, + /// Android MediaCodec via FFmpeg + MediaCodec, /// Software encoding (libx264/openh264) Software, /// No encoder available @@ -64,6 +66,7 @@ impl std::fmt::Display for H264EncoderType { H264EncoderType::Vaapi => write!(f, "VAAPI"), H264EncoderType::Rkmpp => write!(f, "RKMPP"), H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), + H264EncoderType::MediaCodec => write!(f, "MediaCodec"), H264EncoderType::Software => write!(f, "Software"), H264EncoderType::None => write!(f, "None"), } @@ -80,6 +83,7 @@ impl From for H264EncoderType { EncoderBackend::Vaapi => H264EncoderType::Vaapi, EncoderBackend::Rkmpp => H264EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m, + EncoderBackend::MediaCodec => H264EncoderType::MediaCodec, EncoderBackend::Software => H264EncoderType::Software, } } @@ -224,10 +228,10 @@ pub fn detect_best_encoder(width: u32, height: u32) -> (H264EncoderType, Option< } } -/// Encoded frame from hwcodec (cloned for ownership) +/// Encoded frame from hwcodec. #[derive(Debug, Clone)] pub struct HwEncodeFrame { - pub data: Vec, + pub data: Bytes, pub pts: i64, pub key: i32, } @@ -372,14 +376,12 @@ impl H264Encoder { self.frame_count += 1; - match self.inner.encode(data, pts_ms) { + match self.inner.encode_bytes(data, pts_ms) { Ok(frames) => { - // Zero-copy: drain frames from hwcodec buffer instead of cloning - // hwcodec returns &mut Vec, so we can take ownership via drain let owned_frames: Vec = frames - .drain(..) + .into_iter() .map(|f| HwEncodeFrame { - data: f.data, // Move, not clone + data: f.data, pts: f.pts, key: f.key, }) diff --git a/src/video/codec/h265.rs b/src/video/codec/h265.rs index 45faafa2..10dddd99 100644 --- a/src/video/codec/h265.rs +++ b/src/video/codec/h265.rs @@ -45,6 +45,8 @@ pub enum H265EncoderType { Rkmpp, /// V4L2 M2M (ARM generic) V4l2M2m, + /// Android MediaCodec via FFmpeg + MediaCodec, /// Software encoder (libx265) Software, /// No encoder available @@ -61,6 +63,7 @@ impl std::fmt::Display for H265EncoderType { H265EncoderType::Vaapi => write!(f, "VAAPI"), H265EncoderType::Rkmpp => write!(f, "RKMPP"), H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"), + H265EncoderType::MediaCodec => write!(f, "MediaCodec"), H265EncoderType::Software => write!(f, "Software"), H265EncoderType::None => write!(f, "None"), } @@ -76,6 +79,7 @@ impl From for H265EncoderType { EncoderBackend::Vaapi => H265EncoderType::Vaapi, EncoderBackend::Rkmpp => H265EncoderType::Rkmpp, EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m, + EncoderBackend::MediaCodec => H265EncoderType::MediaCodec, EncoderBackend::Software => H265EncoderType::Software, } } @@ -243,10 +247,10 @@ pub fn is_h265_available() -> bool { registry.is_codec_available(VideoEncoderType::H265) } -/// Encoded frame from hwcodec (cloned for ownership) +/// Encoded frame from hwcodec. #[derive(Debug, Clone)] pub struct HwEncodeFrame { - pub data: Vec, + pub data: Bytes, pub pts: i64, pub key: i32, } @@ -465,13 +469,12 @@ impl H265Encoder { ); } - match self.inner.encode(data, pts_ms) { + match self.inner.encode_bytes(data, pts_ms) { Ok(frames) => { - // Zero-copy: drain frames from hwcodec buffer instead of cloning let owned_frames: Vec = frames - .drain(..) + .into_iter() .map(|f| HwEncodeFrame { - data: f.data, // Move, not clone + data: f.data, pts: f.pts, key: f.key, }) diff --git a/src/video/codec/mjpeg_turbo.rs b/src/video/codec/mjpeg_turbo.rs deleted file mode 100644 index 9c8359d1..00000000 --- a/src/video/codec/mjpeg_turbo.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! MJPEG decoder using TurboJPEG (software) -> RGB24. - -use turbojpeg::{Decompressor, Image, PixelFormat as TJPixelFormat}; - -use crate::error::{AppError, Result}; -use crate::video::format::Resolution; - -pub struct MjpegTurboDecoder { - decompressor: Decompressor, - resolution: Resolution, -} - -impl MjpegTurboDecoder { - pub fn new(resolution: Resolution) -> Result { - let decompressor = Decompressor::new().map_err(|e| { - AppError::VideoError(format!("Failed to create turbojpeg decoder: {}", e)) - })?; - Ok(Self { - decompressor, - resolution, - }) - } - - pub fn decode_to_rgb(&mut self, mjpeg: &[u8]) -> Result> { - let header = self - .decompressor - .read_header(mjpeg) - .map_err(|e| AppError::VideoError(format!("turbojpeg read_header failed: {}", e)))?; - - if header.width as u32 != self.resolution.width - || header.height as u32 != self.resolution.height - { - return Err(AppError::VideoError(format!( - "turbojpeg size mismatch: {}x{} (expected {}x{})", - header.width, header.height, self.resolution.width, self.resolution.height - ))); - } - - let pitch = header.width * 3; - let mut image = Image { - pixels: vec![0u8; header.height * pitch], - width: header.width, - pitch, - height: header.height, - format: TJPixelFormat::RGB, - }; - - self.decompressor - .decompress(mjpeg, image.as_deref_mut()) - .map_err(|e| AppError::VideoError(format!("turbojpeg decode failed: {}", e)))?; - - Ok(image.pixels) - } -} diff --git a/src/video/codec/mod.rs b/src/video/codec/mod.rs index 27584170..981fae31 100644 --- a/src/video/codec/mod.rs +++ b/src/video/codec/mod.rs @@ -3,6 +3,10 @@ use hwcodec::common::DataFormat; 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 h264; @@ -16,16 +20,17 @@ pub mod video_codec; pub mod vp8; pub mod vp9; -pub mod mjpeg_turbo; - -#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))] pub mod mjpeg_rkmpp; -pub use convert::{PixelConverter, Yuv420pBuffer}; +#[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 h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat}; pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat}; pub use jpeg::JpegEncoder; -pub use mjpeg_turbo::MjpegTurboDecoder; pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType}; pub use self_check::{ build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell, diff --git a/src/video/codec/registry.rs b/src/video/codec/registry.rs index b75afa10..8f83920d 100644 --- a/src/video/codec/registry.rs +++ b/src/video/codec/registry.rs @@ -96,6 +96,8 @@ pub enum EncoderBackend { Rkmpp, /// V4L2 Memory-to-Memory (ARM) V4l2m2m, + /// Android MediaCodec via FFmpeg + MediaCodec, /// Software encoding (libx264, libx265, libvpx) Software, } @@ -115,6 +117,8 @@ impl EncoderBackend { EncoderBackend::Rkmpp } else if name.contains("v4l2m2m") { EncoderBackend::V4l2m2m + } else if name.contains("mediacodec") { + EncoderBackend::MediaCodec } else { EncoderBackend::Software } @@ -134,6 +138,7 @@ impl EncoderBackend { EncoderBackend::Amf => "AMF", EncoderBackend::Rkmpp => "RKMPP", EncoderBackend::V4l2m2m => "V4L2 M2M", + EncoderBackend::MediaCodec => "MediaCodec", EncoderBackend::Software => "Software", } } @@ -148,6 +153,7 @@ impl EncoderBackend { "amf" => Some(EncoderBackend::Amf), "rkmpp" => Some(EncoderBackend::Rkmpp), "v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m), + "mediacodec" | "android-mediacodec" => Some(EncoderBackend::MediaCodec), "software" | "cpu" => Some(EncoderBackend::Software), _ => None, } diff --git a/src/video/codec/self_check.rs b/src/video/codec/self_check.rs index be6eed32..f82d720e 100644 --- a/src/video/codec/self_check.rs +++ b/src/video/codec/self_check.rs @@ -2,6 +2,8 @@ use serde::Serialize; use std::sync::mpsc; use std::time::{Duration, Instant}; +#[cfg(feature = "android-mediacodec")] +use super::AndroidMediaCodecH264Encoder; use super::{ EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder, VP9Config, VP9Encoder, VideoEncoderType, @@ -235,6 +237,32 @@ fn run_smoke_test( } 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( H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)), codec_name_ffmpeg, diff --git a/src/video/device/linux.rs b/src/video/device/linux.rs index 4ccd6ef8..c3c3c929 100644 --- a/src/video/device/linux.rs +++ b/src/video/device/linux.rs @@ -898,6 +898,17 @@ pub fn enumerate_devices() -> Result> { candidates.push(path); } + if candidates.is_empty() { + let sysfs_entries = video_node_names("/sys/class/video4linux"); + let dev_entries = video_node_names("/dev"); + warn!( + "No video probe candidates after sysfs filter; /dev={:?}, /sys/class/video4linux={:?}", + dev_entries, sysfs_entries + ); + } else { + debug!("Video probe candidates: {:?}", candidates); + } + collapse_rkcif_probe_candidates(&mut candidates); // Second pass: probe the remaining candidates in parallel. Each probe @@ -952,11 +963,35 @@ pub fn enumerate_devices() -> Result> { // 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. 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()); Ok(devices) } +fn video_node_names(dir: &str) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(|entry| entry.ok())) + .filter_map(|entry| entry.file_name().to_str().map(str::to_owned)) + .filter(|name| name.starts_with("video")) + .collect(); + names.sort(); + names +} + pub fn select_recovery_device( devices: &[VideoDeviceInfo], hint: &VideoDeviceRecoveryHint, @@ -1020,6 +1055,33 @@ fn dedup_platform_subdevices(devices: &mut Vec) { }); } +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 /// contend and time out. Keep one node per board (lowest `videoN`). fn collapse_rkcif_probe_candidates(candidates: &mut Vec) { @@ -1123,6 +1185,20 @@ fn sysfs_maybe_capture(path: &Path) -> bool { .to_lowercase(); 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 capture_hints = [ "capture", diff --git a/src/video/device/mod.rs b/src/video/device/mod.rs index d959d48e..4461b8a2 100644 --- a/src/video/device/mod.rs +++ b/src/video/device/mod.rs @@ -6,7 +6,10 @@ mod linux; mod windows; #[cfg(unix)] -pub use linux::*; +pub use linux::{ + enumerate_devices, find_best_device, select_recovery_device, VideoDevice, VideoDeviceInfo, + VideoDeviceRecoveryHint, +}; #[cfg(windows)] pub use windows::*; @@ -33,3 +36,6 @@ pub(crate) fn is_rkcif_driver(driver: &str) -> bool { pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool { is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver) } + +#[cfg(unix)] +pub(crate) use linux::parse_bridge_kind; diff --git a/src/video/mod.rs b/src/video/mod.rs index d3fff9d9..204cafa5 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -8,13 +8,21 @@ pub mod codec_constraints; pub mod device; pub mod format; pub mod frame; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod pipeline; pub mod signal; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod stream_manager; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod streamer; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod traits; +#[cfg(any(feature = "android", feature = "desktop"))] pub mod types; +pub use capture::{CaptureMeta, CaptureStream}; +#[cfg(feature = "android-mediacodec")] +pub use codec::{AndroidH264Packet, AndroidMediaCodecH264Encoder}; pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer}; pub use device::{VideoDevice, VideoDeviceInfo}; pub use format::PixelFormat; diff --git a/src/video/pipeline/encoder_state.rs b/src/video/pipeline/encoder_state.rs index 612df68b..c97c5cb7 100644 --- a/src/video/pipeline/encoder_state.rs +++ b/src/video/pipeline/encoder_state.rs @@ -1,14 +1,21 @@ use crate::error::{AppError, Result}; -use crate::video::codec::convert::{Nv12Converter, PixelConverter}; +use crate::video::codec::convert::{MjpegToNv12Decoder, Nv12Converter, PixelConverter}; use crate::video::codec::h264::{H264Config, H264Encoder, H264InputFormat}; use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat}; use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType}; use crate::video::codec::traits::EncoderConfig; use crate::video::codec::vp8::{VP8Config, VP8Encoder}; use crate::video::codec::vp9::{VP9Config, VP9Encoder}; -use crate::video::codec::MjpegTurboDecoder; +#[cfg(feature = "android-mediacodec")] +use crate::video::codec::AndroidMediaCodecH264Encoder; +#[cfg(feature = "android-mediacodec")] +use crate::video::codec::AndroidMediaCodecMjpegDecoder; use crate::video::format::{PixelFormat, Resolution}; -#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +use bytes::Bytes; +#[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") +))] use hwcodec::ffmpeg_hw::{ last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline, }; @@ -22,9 +29,15 @@ pub(super) struct EncoderThreadState { pub(super) nv12_converter: Option, pub(super) yuv420p_converter: Option, pub(super) encoder_needs_yuv420p: bool, - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] pub(super) ffmpeg_hw_pipeline: Option, - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] pub(super) ffmpeg_hw_enabled: bool, pub(super) fps: u32, pub(super) codec: VideoEncoderType, @@ -39,7 +52,7 @@ pub(super) trait VideoEncoderTrait: Send { } pub(super) struct EncodedFrame { - pub(super) data: Vec, + pub(super) data: Bytes, pub(super) key: i32, } @@ -70,6 +83,25 @@ impl VideoEncoderTrait for H264EncoderWrapper { } } +fn create_h264_encoder( + config: &SharedVideoPipelineConfig, + input_format: H264InputFormat, + codec_name: &str, +) -> Result> { + let encoder = H264Encoder::with_codec( + H264Config { + base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()), + bitrate_kbps: config.bitrate_kbps(), + gop_size: config.gop_size(), + fps: config.fps, + input_format, + }, + codec_name, + )?; + info!("Created H264 encoder: {}", encoder.codec_name()); + Ok(Box::new(H264EncoderWrapper(encoder))) +} + struct H265EncoderWrapper(H265Encoder); impl VideoEncoderTrait for H265EncoderWrapper { @@ -97,6 +129,35 @@ 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> { + 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); impl VideoEncoderTrait for VP8EncoderWrapper { @@ -105,7 +166,7 @@ impl VideoEncoderTrait for VP8EncoderWrapper { Ok(frames .into_iter() .map(|f| EncodedFrame { - data: f.data, + data: f.data.into(), key: f.key, }) .collect()) @@ -130,7 +191,7 @@ impl VideoEncoderTrait for VP9EncoderWrapper { Ok(frames .into_iter() .map(|f| EncodedFrame { - data: f.data, + data: f.data.into(), key: f.key, }) .collect()) @@ -148,17 +209,100 @@ impl VideoEncoderTrait for VP9EncoderWrapper { } pub(super) enum MjpegDecoderKind { - Turbo(MjpegTurboDecoder), + #[cfg(feature = "android-mediacodec")] + AndroidMediaCodec { + decoder: AndroidMediaCodecMjpegDecoder, + fallback: Box, + fallback_active: bool, + output: Vec, + }, + Libyuv { + decoder: MjpegToNv12Decoder, + }, } impl MjpegDecoderKind { - pub(super) fn decode(&mut self, data: &[u8]) -> Result> { + pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> { match self { - MjpegDecoderKind::Turbo(decoder) => decoder.decode_to_rgb(data), + #[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), } } } +fn libyuv_mjpeg_decoder(resolution: Resolution) -> MjpegDecoderKind { + MjpegDecoderKind::Libyuv { + decoder: MjpegToNv12Decoder::new(resolution), + } +} + +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)"); + Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12)) +} + pub(super) fn build_encoder_state( config: &SharedVideoPipelineConfig, ) -> Result { @@ -256,9 +400,15 @@ pub(super) fn build_encoder_state( } }; - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] let is_rkmpp_encoder = selected_codec_name.contains("rkmpp"); - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] if needs_mjpeg_decode && is_rkmpp_encoder && matches!( @@ -298,9 +448,15 @@ pub(super) fn build_encoder_state( nv12_converter: None, yuv420p_converter: None, encoder_needs_yuv420p: false, - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] ffmpeg_hw_pipeline: Some(pipeline), - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] ffmpeg_hw_enabled: true, fps: config.fps, codec: config.output_codec, @@ -309,16 +465,8 @@ pub(super) fn build_encoder_state( } let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode { - info!( - "MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)", - config.input_format - ); - ( - Some(MjpegDecoderKind::Turbo(MjpegTurboDecoder::new( - config.resolution, - )?)), - PixelFormat::Rgb24, - ) + let (decoder, format) = create_mjpeg_decoder(config.resolution)?; + (Some(decoder), format) } else { (None, config.input_format) }; @@ -347,18 +495,40 @@ pub(super) fn build_encoder_state( ); } - let encoder = H264Encoder::with_codec( - H264Config { - base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()), - bitrate_kbps: config.bitrate_kbps(), - gop_size: config.gop_size(), - fps: config.fps, - input_format, - }, - &codec_name, - )?; - info!("Created H264 encoder: {}", encoder.codec_name()); - Box::new(H264EncoderWrapper(encoder)) + #[cfg(feature = "android-mediacodec")] + { + 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 => { let codec_name = selected_codec_name.clone(); @@ -452,6 +622,11 @@ pub(super) fn build_encoder_state( pipeline_input_format, PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420 ) + } else if codec_name.contains("mediacodec") { + matches!( + pipeline_input_format, + PixelFormat::Nv12 | PixelFormat::Yuv420 + ) } else { false }; @@ -501,9 +676,15 @@ pub(super) fn build_encoder_state( nv12_converter, yuv420p_converter, encoder_needs_yuv420p: needs_yuv420p, - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] ffmpeg_hw_pipeline: None, - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] ffmpeg_hw_enabled: false, fps: config.fps, codec: config.output_codec, @@ -527,6 +708,12 @@ fn h264_direct_input_format( PixelFormat::Nv24 => Some(H264InputFormat::Nv24), _ => 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") { match input_format { PixelFormat::Nv12 => Some(H264InputFormat::Nv12), diff --git a/src/video/pipeline/shared.rs b/src/video/pipeline/shared.rs index 42c96870..f649797d 100644 --- a/src/video/pipeline/shared.rs +++ b/src/video/pipeline/shared.rs @@ -38,6 +38,7 @@ const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500; const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20); /// Throttle repeated encoding errors to avoid log flooding const ENCODE_ERROR_THROTTLE_SECS: u64 = 5; +const INVALID_MJPEG_LOG_THROTTLE_SECS: u64 = 5; static PROCESS_START: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -60,7 +61,10 @@ use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame}; use crate::video::signal::SignalStatus; const MIN_CAPTURE_FRAME_SIZE: usize = 128; -#[cfg(any(target_arch = "aarch64", target_arch = "arm"))] +#[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") +))] use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error; /// Encoded video frame for distribution @@ -480,9 +484,15 @@ impl SharedVideoPipeline { fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> { match cmd { PipelineCmd::SetBitrate { bitrate_kbps, gop } => { - #[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))] + #[cfg(any( + not(any(target_arch = "aarch64", target_arch = "arm")), + target_os = "android" + ))] let _ = gop; - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] if state.ffmpeg_hw_enabled { if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline { pipeline @@ -649,12 +659,14 @@ impl SharedVideoPipeline { *guard = Some(cmd_tx); } - // Encoder loop (runs on tokio, consumes latest frame) + // Encoder loop uses a dedicated OS thread because FFmpeg/MediaCodec work is synchronous. { let pipeline = pipeline.clone(); let latest_frame = latest_frame.clone(); - tokio::spawn(async move { - let mut frame_count: u64 = 0; + let handle = tokio::runtime::Handle::current(); + std::thread::spawn(move || { + let mut input_frame_count: u64 = 0; + let mut encoded_frame_count: u64 = 0; let mut last_fps_time = Instant::now(); let mut fps_frame_count: u64 = 0; let mut last_seq = *frame_seq_rx.borrow(); @@ -662,7 +674,7 @@ impl SharedVideoPipeline { let mut suppressed_encode_errors: HashMap = HashMap::new(); while pipeline.running_flag.load(Ordering::Acquire) { - if frame_seq_rx.changed().await.is_err() { + if handle.block_on(frame_seq_rx.changed()).is_err() { break; } if !pipeline.running_flag.load(Ordering::Acquire) { @@ -694,15 +706,19 @@ impl SharedVideoPipeline { None => continue, }; - match pipeline.encode_frame_sync(&mut encoder_state, &frame, frame_count) { - Ok(Some(encoded_frame)) => { - let encoded_arc = Arc::new(encoded_frame); - pipeline.broadcast_encoded(encoded_arc).await; + input_frame_count = input_frame_count.wrapping_add(1); - frame_count += 1; - fps_frame_count += 1; + match pipeline.encode_frame_sync(&mut encoder_state, &frame, input_frame_count) + { + Ok(encoded_frames) => { + for encoded_frame in encoded_frames { + let encoded_arc = Arc::new(encoded_frame); + handle.block_on(pipeline.broadcast_encoded(encoded_arc)); + + encoded_frame_count = encoded_frame_count.wrapping_add(1); + fps_frame_count += 1; + } } - Ok(None) => {} Err(e) => { log_encoding_error( &encode_error_throttler, @@ -718,8 +734,15 @@ impl SharedVideoPipeline { fps_frame_count = 0; last_fps_time = Instant::now(); - let mut s = pipeline.stats.lock().await; - s.current_fps = current_fps; + handle.block_on(async { + let mut s = pipeline.stats.lock().await; + s.current_fps = current_fps; + }); + trace!( + "Shared pipeline processed {} input frames, emitted {} encoded frames", + input_frame_count, + encoded_frame_count + ); } } @@ -847,6 +870,8 @@ impl SharedVideoPipeline { let mut sequence: u64 = 0; let mut consecutive_timeouts: u32 = 0; let capture_error_throttler = LogThrottler::with_secs(5); + let invalid_mjpeg_throttler = + LogThrottler::with_secs(INVALID_MJPEG_LOG_THROTTLE_SECS); let mut suppressed_capture_errors: HashMap = HashMap::new(); while pipeline.running_flag.load(Ordering::Acquire) { @@ -1207,6 +1232,20 @@ impl SharedVideoPipeline { } owned.truncate(frame_size); + if pixel_format.is_compressed() && !VideoFrame::is_valid_jpeg_bytes(&owned) { + if invalid_mjpeg_throttler.should_log("invalid_mjpeg_capture_frame") { + let b0 = owned.first().copied().unwrap_or_default(); + let b1 = owned.get(1).copied().unwrap_or_default(); + warn!( + "Dropping invalid MJPEG capture frame: size={}, starts with 0x{:02x} 0x{:02x}", + owned.len(), + b0, + b1 + ); + } + continue; + } + // Notify streaming only after frame validation passes — // stale/warm-up frames from V4L2 kernel queues can cause // DQBUF Ok with invalid data, which would prematurely @@ -1244,7 +1283,7 @@ impl SharedVideoPipeline { state: &mut EncoderThreadState, frame: &VideoFrame, frame_count: u64, - ) -> Result> { + ) -> Result> { let fps = state.fps; let codec = state.codec; let input_format = state.input_format; @@ -1268,7 +1307,10 @@ impl SharedVideoPipeline { current_ts_us.saturating_sub(start_ts_us) / 1000 }; - #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + #[cfg(all( + any(target_arch = "aarch64", target_arch = "arm"), + not(target_os = "android") + ))] if state.ffmpeg_hw_enabled { if input_format != PixelFormat::Mjpeg { return Err(AppError::VideoError( @@ -1295,17 +1337,17 @@ impl SharedVideoPipeline { if let Some((data, is_keyframe)) = packet { let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; - return Ok(Some(EncodedVideoFrame { + return Ok(vec![EncodedVideoFrame { data: Bytes::from(data), pts_ms, is_keyframe, sequence, duration: Duration::from_millis(1000 / fps as u64), codec, - })); + }]); } - return Ok(None); + return Ok(Vec::new()); } let decoded_buf = if input_format.is_compressed() { @@ -1313,12 +1355,26 @@ impl SharedVideoPipeline { .mjpeg_decoder .as_mut() .ok_or_else(|| AppError::VideoError("MJPEG decoder not initialized".to_string()))?; - let decoded = decoder.decode(raw_frame)?; + let decoded = match decoder.decode(raw_frame) { + Ok(decoded) => decoded, + Err(err) => { + warn!("Dropping undecodable MJPEG frame before encode: {}", err); + return Ok(Vec::new()); + } + }; Some(decoded) } else { None }; - let raw_frame = decoded_buf.as_deref().unwrap_or(raw_frame); + let compacted_buf = if decoded_buf.is_none() { + compact_strided_frame_for_encoder(frame, raw_frame)? + } else { + None + }; + let raw_frame = decoded_buf + .as_deref() + .or(compacted_buf.as_deref()) + .unwrap_or(raw_frame); // Debug log for H265 if codec == VideoEncoderType::H265 && frame_count % 30 == 1 { @@ -1365,8 +1421,24 @@ impl SharedVideoPipeline { match encode_result { Ok(frames) => { - if !frames.is_empty() { - let encoded = frames.into_iter().next().unwrap(); + if frames.is_empty() { + if codec == VideoEncoderType::H265 { + warn!( + "[Pipeline-H265] Encoder returned no frames for frame #{}", + frame_count + ); + } else { + trace!( + "Encoder returned no frames for input frame #{} ({})", + frame_count, + codec + ); + } + return Ok(Vec::new()); + } + + let mut encoded_frames = Vec::with_capacity(frames.len()); + for encoded in frames { let is_keyframe = encoded.key == 1; let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; if codec == VideoEncoderType::H264 { @@ -1390,23 +1462,17 @@ impl SharedVideoPipeline { } } - Ok(Some(EncodedVideoFrame { - data: Bytes::from(encoded.data), + encoded_frames.push(EncodedVideoFrame { + data: encoded.data, pts_ms, is_keyframe, sequence, duration: Duration::from_millis(1000 / fps as u64), codec, - })) - } else { - if codec == VideoEncoderType::H265 { - warn!( - "[Pipeline-H265] Encoder returned no frames for frame #{}", - frame_count - ); - } - Ok(None) + }); } + + Ok(encoded_frames) } Err(e) => { if codec == VideoEncoderType::H265 { @@ -1490,6 +1556,174 @@ impl SharedVideoPipeline { } } +fn compact_strided_frame_for_encoder(frame: &VideoFrame, data: &[u8]) -> Result>> { + let width = frame.resolution.width as usize; + let height = frame.resolution.height as usize; + let stride = frame.stride as usize; + if width == 0 || height == 0 || stride == 0 || frame.format.is_compressed() { + return Ok(None); + } + + let compact_size = match frame.format { + PixelFormat::Nv12 | PixelFormat::Nv21 | PixelFormat::Yuv420 | PixelFormat::Yvu420 => { + width * height * 3 / 2 + } + PixelFormat::Nv16 | PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy => { + width * height * 2 + } + PixelFormat::Nv24 | PixelFormat::Rgb24 | PixelFormat::Bgr24 => width * height * 3, + PixelFormat::Rgb565 => width * height * 2, + PixelFormat::Grey => width * height, + PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None), + }; + + if data.len() == compact_size { + return Ok(None); + } + + let mut out = vec![0u8; compact_size]; + match frame.format { + PixelFormat::Nv12 | PixelFormat::Nv21 => { + let src_y_size = stride * height; + let src_uv_size = stride * height / 2; + require_len(data, src_y_size + src_uv_size, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, width, width, height); + copy_rows( + data, + src_y_size, + stride, + &mut out, + width * height, + width, + width, + height / 2, + ); + } + PixelFormat::Yuv420 | PixelFormat::Yvu420 => { + let src_y_size = stride * height; + let src_chroma_stride = stride / 2; + let src_chroma_size = src_chroma_stride * height / 2; + let dst_y_size = width * height; + let dst_chroma_stride = width / 2; + let dst_chroma_size = dst_chroma_stride * height / 2; + require_len(data, src_y_size + src_chroma_size * 2, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, width, width, height); + copy_rows( + data, + src_y_size, + src_chroma_stride, + &mut out, + dst_y_size, + dst_chroma_stride, + dst_chroma_stride, + height / 2, + ); + copy_rows( + data, + src_y_size + src_chroma_size, + src_chroma_stride, + &mut out, + dst_y_size + dst_chroma_size, + dst_chroma_stride, + dst_chroma_stride, + height / 2, + ); + } + PixelFormat::Nv16 => { + let src_y_size = stride * height; + require_len(data, src_y_size + stride * height, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, width, width, height); + copy_rows( + data, + src_y_size, + stride, + &mut out, + width * height, + width, + width, + height, + ); + } + PixelFormat::Nv24 => { + let src_y_size = stride * height; + let src_uv_stride = stride * 2; + require_len( + data, + src_y_size + src_uv_stride * height, + frame.format, + stride, + )?; + copy_rows(data, 0, stride, &mut out, 0, width, width, height); + copy_rows( + data, + src_y_size, + src_uv_stride, + &mut out, + width * height, + width * 2, + width * 2, + height, + ); + } + PixelFormat::Yuyv | PixelFormat::Yvyu | PixelFormat::Uyvy | PixelFormat::Rgb565 => { + let row_bytes = width * 2; + require_len(data, stride * height, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height); + } + PixelFormat::Rgb24 | PixelFormat::Bgr24 => { + let row_bytes = width * 3; + require_len(data, stride * height, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, row_bytes, row_bytes, height); + } + PixelFormat::Grey => { + require_len(data, stride * height, frame.format, stride)?; + copy_rows(data, 0, stride, &mut out, 0, width, width, height); + } + PixelFormat::Mjpeg | PixelFormat::Jpeg => return Ok(None), + } + + trace!( + "Compacted strided {} frame for encoder: {} -> {} bytes (stride={}, width={})", + frame.format, + data.len(), + out.len(), + stride, + width + ); + Ok(Some(out)) +} + +fn require_len(data: &[u8], required: usize, format: PixelFormat, stride: usize) -> Result<()> { + if data.len() < required { + return Err(AppError::VideoError(format!( + "{} frame too small for stride compaction: {} < {} (stride={})", + format, + data.len(), + required, + stride + ))); + } + Ok(()) +} + +fn copy_rows( + src: &[u8], + src_offset: usize, + src_stride: usize, + dst: &mut [u8], + dst_offset: usize, + dst_stride: usize, + row_bytes: usize, + rows: usize, +) { + for row in 0..rows { + let src_start = src_offset + row * src_stride; + let dst_start = dst_offset + row * dst_stride; + dst[dst_start..dst_start + row_bytes] + .copy_from_slice(&src[src_start..src_start + row_bytes]); + } +} + impl Drop for SharedVideoPipeline { fn drop(&mut self) { let _ = self.running.send(false); diff --git a/src/web/handlers/config/apply.rs b/src/web/handlers/config/apply.rs index d0819c39..f9f8b5af 100644 --- a/src/web/handlers/config/apply.rs +++ b/src/web/handlers/config/apply.rs @@ -415,7 +415,7 @@ pub async fn apply_rustdesk_config( let mut rustdesk_guard = state.rustdesk.write().await; let mut credentials_to_save = None; - if old_config.enabled && !new_config.enabled { + if !new_config.enabled { if let Some(ref service) = *rustdesk_guard { service .stop() @@ -493,7 +493,7 @@ pub async fn apply_rtsp_config( let mut rtsp_guard = state.rtsp.write().await; - if old_config.enabled && !new_config.enabled { + if !new_config.enabled { if let Some(ref service) = *rtsp_guard { service .stop() diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index 8886be64..6800a1a8 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -21,10 +21,13 @@ pub use hid::{get_hid_config, update_hid_config}; #[cfg(unix)] pub use msd::{get_msd_config, update_msd_config}; pub use redfish::{get_redfish_config, update_redfish_config}; -pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config}; +pub use rtsp::{ + get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config, +}; pub use rustdesk::{ get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id, - regenerate_device_password, update_rustdesk_config, + regenerate_device_password, start_rustdesk_service, stop_rustdesk_service, + update_rustdesk_config, }; pub use stream::{get_stream_config, update_stream_config}; pub use video::{get_video_config, update_video_config}; diff --git a/src/web/handlers/update_api.rs b/src/web/handlers/update_api.rs index 3750f973..bf607b22 100644 --- a/src/web/handlers/update_api.rs +++ b/src/web/handlers/update_api.rs @@ -9,6 +9,12 @@ pub async fn update_overview( State(state): State>, axum::extract::Query(query): axum::extract::Query, ) -> Result> { + 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 response = state.update.overview(channel).await?; Ok(Json(response)) @@ -18,6 +24,12 @@ pub async fn update_upgrade( State(state): State>, Json(req): Json, ) -> Result> { + 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())?; Ok(Json(LoginResponse { @@ -26,6 +38,14 @@ pub async fn update_upgrade( })) } -pub async fn update_status(State(state): State>) -> Json { - Json(state.update.status().await) +pub async fn update_status( + State(state): State>, +) -> Result> { + if cfg!(feature = "android") { + return Err(AppError::BadRequest( + "Online upgrade is disabled on Android".to_string(), + )); + } + + Ok(Json(state.update.status().await)) } diff --git a/src/webrtc/universal_session.rs b/src/webrtc/universal_session.rs index 1bd9f1b5..90f9adc9 100644 --- a/src/webrtc/universal_session.rs +++ b/src/webrtc/universal_session.rs @@ -853,10 +853,20 @@ impl UniversalSession { handle.abort(); } - self.pc - .close() - .await - .map_err(|e| AppError::VideoError(format!("Failed to close peer connection: {}", e)))?; + if let Err(e) = self.pc.close().await { + let error = e.to_string(); + if error.contains("mpsc send: channel closed") { + debug!( + "{} session {} peer connection was already closed: {}", + self.codec, self.session_id, error + ); + } else { + return Err(AppError::VideoError(format!( + "Failed to close peer connection: {}", + error + ))); + } + } let _ = self.state.send(ConnectionState::Closed); diff --git a/web/src/api/index.ts b/web/src/api/index.ts index d2b4ca43..e9ffc237 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -57,7 +57,7 @@ export interface FeatureCapability { } export interface PlatformCapabilities { - mode: 'linux' | 'windows' + mode: 'android_amlogic' | 'linux' | 'windows' mode_label: string video_capture: FeatureCapability encoder: FeatureCapability diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index ebae469c..62c36620 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -114,6 +114,7 @@ const configStore = useConfigStore() const authStore = useAuthStore() 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 activeSection = ref('appearance') @@ -275,6 +276,7 @@ async function loadSectionData(section: SettingsSectionId) { await loadRtspConfig() return case 'about': + if (isAndroid.value) return await Promise.all([ loadUpdateOverview(), refreshUpdateStatus(), @@ -1882,6 +1884,7 @@ async function triggerAutoRestart() { } async function loadUpdateOverview() { + if (isAndroid.value) return updateLoading.value = true try { updateOverview.value = await updateApi.overview(updateChannel.value) @@ -1897,6 +1900,7 @@ async function loadUpdateOverview() { } async function refreshUpdateStatus() { + if (isAndroid.value) return try { updateStatus.value = await updateApi.status() @@ -1921,6 +1925,7 @@ function stopUpdatePolling() { } function startUpdatePolling() { + if (isAndroid.value) return if (updateStatusTimer !== null) return updateStatusTimer = window.setInterval(async () => { await refreshUpdateStatus() @@ -1935,6 +1940,7 @@ function startUpdatePolling() { } async function startOnlineUpgrade() { + if (isAndroid.value) return try { updateSawRestarting.value = false updateSawRequestFailure.value = false @@ -2223,7 +2229,7 @@ onMounted(async () => { }) watch(updateChannel, async () => { - if (activeSection.value === 'about') { + if (activeSection.value === 'about' && !isAndroid.value) { await loadUpdateOverview() } }) @@ -4276,7 +4282,7 @@ watch(isWindows, () => {
- +
{{ t('settings.onlineUpgrade') }}