From b31aae284d3a79fd38446ca2dcd8bfc1f1ce8348 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 24 May 2026 08:37:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- Cargo.toml | 236 ++++-- android/.gitignore | 7 + android/app/build.gradle.kts | 566 ++++++++++++++ android/app/src/main/AndroidManifest.xml | 36 + .../cn/one_kvm/androidhost/BootReceiver.kt | 13 + .../cn/one_kvm/androidhost/HostSettings.kt | 33 + .../java/cn/one_kvm/androidhost/LogConfig.kt | 30 + .../java/cn/one_kvm/androidhost/LogStore.kt | 71 ++ .../cn/one_kvm/androidhost/MainActivity.kt | 452 +++++++++++ .../cn/one_kvm/androidhost/NativeBridge.kt | 21 + .../cn/one_kvm/androidhost/OneKvmService.kt | 413 ++++++++++ .../one_kvm/androidhost/ServiceStatusStore.kt | 75 ++ .../main/res/drawable/ic_launcher_one_kvm.xml | 38 + .../src/main/res/drawable/ic_stat_one_kvm.xml | 13 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/styles.xml | 7 + android/build.gradle.kts | 3 + android/gradle.properties | 3 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 251 ++++++ android/gradlew.bat | 94 +++ android/native/Cargo.toml | 21 + .../native/src/bin/one-kvm-android-host.rs | 24 + android/native/src/lib.rs | 182 +++++ android/settings.gradle.kts | 18 + build/build-android.sh | 88 +++ build/cross/Dockerfile.android | 300 +++++++ build/cross/Dockerfile.arm64 | 6 + build/cross/Dockerfile.armv7 | 6 + build/cross/Dockerfile.x86_64 | 7 + libs/hwcodec/Cargo.toml | 4 +- libs/hwcodec/build.rs | 230 +++++- libs/hwcodec/cpp/common/util.cpp | 36 +- .../cpp/ffmpeg_ram/ffmpeg_ram_decode.cpp | 7 + .../cpp/ffmpeg_ram/ffmpeg_ram_encode.cpp | 316 ++++++-- libs/hwcodec/cpp/ffmpeg_ram/ffmpeg_ram_ffi.h | 7 + libs/hwcodec/src/common.rs | 4 +- libs/hwcodec/src/ffmpeg_ram/encode.rs | 156 +++- libs/hwcodec/src/ffmpeg_ram/mod.rs | 10 +- libs/hwcodec/src/lib.rs | 5 +- libs/v4l2r/bindgen.rs | 2 - res/vcpkg/libyuv/Cargo.toml | 2 +- res/vcpkg/libyuv/build.rs | 329 +++++++- res/vcpkg/libyuv/cpp/yuv_ffi.h | 19 +- res/vcpkg/libyuv/src/lib.rs | 111 ++- scripts/build-android-alsa.sh | 219 ++++++ scripts/build-android-ffmpeg-mediacodec.sh | 296 +++++++ scripts/build-android-libyuv.sh | 189 +++++ scripts/build-android-opus.sh | 186 +++++ scripts/build-android-turbojpeg.sh | 178 +++++ src/atx/controller.rs | 39 +- src/atx/hidraw_linux.rs | 2 +- src/atx/mod.rs | 7 +- src/audio/capture.rs | 6 +- src/audio/capture_android.rs | 292 +++++++ src/audio/controller.rs | 6 +- src/audio/device.rs | 6 +- src/audio/device_android.rs | 185 +++++ src/audio/recovery.rs | 2 +- src/config/schema/atx.rs | 1 - src/config/schema/common.rs | 1 - src/config/schema/hid.rs | 1 - src/config/schema/mod.rs | 1 - src/config/schema/stream.rs | 1 - src/diagnostics/linux.rs | 108 +++ src/hid/ch9329.rs | 3 +- src/hid/ch9329_proto.rs | 5 +- src/hid/mod.rs | 2 +- src/hid/otg.rs | 12 +- src/lib.rs | 33 +- src/otg/configfs.rs | 28 +- src/otg/mod.rs | 17 +- src/platform/android.rs | 43 + src/platform/android_bionic.rs | 175 +++++ src/platform/capabilities.rs | 20 +- src/platform/defaults.rs | 66 +- src/platform/mod.rs | 6 + src/redfish/routes/mod.rs | 4 +- src/runtime/android.rs | 735 ++++++++++++++++++ src/runtime/mod.rs | 4 + src/stream/mjpeg.rs | 19 +- src/stream/mod.rs | 2 + src/utils/mod.rs | 4 +- src/video/codec/android_mediacodec.rs | 122 +++ src/video/codec/android_mjpeg.rs | 137 ++++ src/video/codec/convert.rs | 82 +- src/video/codec/h264.rs | 16 +- src/video/codec/h265.rs | 15 +- src/video/codec/mjpeg_turbo.rs | 54 -- src/video/codec/mod.rs | 15 +- src/video/codec/registry.rs | 6 + src/video/codec/self_check.rs | 28 + src/video/device/linux.rs | 76 ++ src/video/device/mod.rs | 8 +- src/video/mod.rs | 8 + src/video/pipeline/encoder_state.rs | 265 ++++++- src/video/pipeline/shared.rs | 306 +++++++- src/web/handlers/config/apply.rs | 4 +- src/web/handlers/config/mod.rs | 7 +- src/web/handlers/update_api.rs | 24 +- src/webrtc/universal_session.rs | 18 +- web/src/api/index.ts | 2 +- web/src/views/SettingsView.vue | 10 +- 105 files changed, 7900 insertions(+), 473 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/BootReceiver.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/HostSettings.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/LogConfig.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/NativeBridge.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/OneKvmService.kt create mode 100644 android/app/src/main/java/cn/one_kvm/androidhost/ServiceStatusStore.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_one_kvm.xml create mode 100644 android/app/src/main/res/drawable/ic_stat_one_kvm.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/native/Cargo.toml create mode 100644 android/native/src/bin/one-kvm-android-host.rs create mode 100644 android/native/src/lib.rs create mode 100644 android/settings.gradle.kts create mode 100644 build/build-android.sh create mode 100644 build/cross/Dockerfile.android create mode 100644 scripts/build-android-alsa.sh create mode 100644 scripts/build-android-ffmpeg-mediacodec.sh create mode 100644 scripts/build-android-libyuv.sh create mode 100644 scripts/build-android-opus.sh create mode 100644 scripts/build-android-turbojpeg.sh create mode 100644 src/audio/capture_android.rs create mode 100644 src/audio/device_android.rs create mode 100644 src/platform/android.rs create mode 100644 src/platform/android_bionic.rs create mode 100644 src/runtime/android.rs create mode 100644 src/runtime/mod.rs create mode 100644 src/video/codec/android_mediacodec.rs create mode 100644 src/video/codec/android_mjpeg.rs delete mode 100644 src/video/codec/mjpeg_turbo.rs 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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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') }}