mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
feat: 新增安卓平台支持
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Rust
|
# Rust
|
||||||
/target/
|
target/
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
@@ -31,6 +31,7 @@ Thumbs.db
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist/
|
/dist/
|
||||||
/build-staging
|
/build-staging
|
||||||
|
/.tmp/
|
||||||
|
|
||||||
# Frontend (built files)
|
# Frontend (built files)
|
||||||
/web/node_modules/
|
/web/node_modules/
|
||||||
|
|||||||
236
Cargo.toml
236
Cargo.toml
@@ -9,119 +9,245 @@ repository = "https://github.com/mofeng-git/One-KVM"
|
|||||||
keywords = ["kvm", "ipkvm", "remote-management", "embedded"]
|
keywords = ["kvm", "ipkvm", "remote-management", "embedded"]
|
||||||
categories = ["embedded", "network-programming"]
|
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]
|
[dependencies]
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"], optional = true }
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
tokio-util = { version = "0.7", features = ["rt"], optional = true }
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["ws", "multipart", "tokio"] }
|
axum = { version = "0.8", features = ["ws", "multipart", "tokio"], optional = true }
|
||||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
axum-extra = { version = "0.12", features = ["cookie"], optional = true }
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] }
|
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"], optional = true }
|
||||||
|
|
||||||
# Database - Use bundled SQLite for static linking
|
# 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
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
serde_json = "1"
|
serde_json = { version = "1", optional = true }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = { version = "0.1", optional = true }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"] }
|
tracing-log = { version = "0.2", optional = true }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "tracing-log"], optional = true }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2"
|
thiserror = { version = "2", optional = true }
|
||||||
anyhow = "1"
|
anyhow = { version = "1", optional = true }
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
argon2 = "0.5"
|
argon2 = { version = "0.5", optional = true }
|
||||||
rand = "0.9"
|
rand = { version = "0.9", optional = true }
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"], optional = true }
|
||||||
base64 = "0.22"
|
base64 = { version = "0.22", optional = true }
|
||||||
nix = { version = "0.30", features = ["fs", "net", "hostname", "poll"] }
|
|
||||||
|
|
||||||
# HTTP client (for URL downloads)
|
# HTTP client (for URL downloads)
|
||||||
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
# Use rustls by default, but allow native-tls for systems with older GLIBC
|
||||||
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false }
|
reqwest = { version = "0.13", features = ["stream", "rustls", "json"], default-features = false, optional = true }
|
||||||
urlencoding = "2"
|
urlencoding = { version = "2", optional = true }
|
||||||
|
|
||||||
# Static file embedding
|
# Static file embedding
|
||||||
rust-embed = { version = "8", features = ["compression", "debug-embed"] }
|
rust-embed = { version = "8", features = ["compression", "debug-embed"], optional = true }
|
||||||
mime_guess = "2"
|
mime_guess = { version = "2", optional = true }
|
||||||
|
|
||||||
# TLS/HTTPS
|
# TLS/HTTPS
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"], optional = true }
|
||||||
rcgen = "0.14"
|
rcgen = { version = "0.14", optional = true }
|
||||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
axum-server = { version = "0.8", features = ["tls-rustls"], optional = true }
|
||||||
|
|
||||||
# CLI argument parsing
|
# CLI argument parsing
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"], optional = true }
|
||||||
|
|
||||||
# Time (cookie max_age + RFC3339 timestamps)
|
# 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 handling
|
||||||
bytes = "1"
|
bytes = { version = "1", optional = true }
|
||||||
bytemuck = { version = "1.24", features = ["derive"] }
|
bytemuck = { version = "1.24", features = ["derive"], optional = true }
|
||||||
|
|
||||||
# Frame deduplication (hash-based comparison)
|
# Frame deduplication (hash-based comparison)
|
||||||
xxhash-rust = { version = "0.8", features = ["xxh64"] }
|
xxhash-rust = { version = "0.8", features = ["xxh64"], optional = true }
|
||||||
|
|
||||||
# Async channels
|
# Async channels
|
||||||
async-stream = "0.3"
|
async-stream = { version = "0.3", optional = true }
|
||||||
futures = "0.3"
|
futures = { version = "0.3", optional = true }
|
||||||
|
|
||||||
# WebSocket client (for ttyd proxy)
|
# WebSocket client (for ttyd proxy)
|
||||||
tokio-tungstenite = "0.28"
|
tokio-tungstenite = { version = "0.28", optional = true }
|
||||||
|
|
||||||
# High-performance synchronization
|
# High-performance synchronization
|
||||||
parking_lot = "0.12"
|
parking_lot = { version = "0.12", optional = true }
|
||||||
arc-swap = "1.8"
|
arc-swap = { version = "1.8", optional = true }
|
||||||
|
|
||||||
# WebRTC
|
# WebRTC
|
||||||
webrtc = "0.14"
|
webrtc = { version = "0.14", optional = true }
|
||||||
rtp = "0.14"
|
rtp = { version = "0.14", optional = true }
|
||||||
rtsp-types = "0.1"
|
rtsp-types = { version = "0.1", optional = true }
|
||||||
sdp-types = "0.1"
|
sdp-types = { version = "0.1", optional = true }
|
||||||
|
|
||||||
# HID (serial port for CH9329)
|
# HID (serial port for CH9329)
|
||||||
serialport = "4"
|
serialport = { version = "4", optional = true }
|
||||||
async-trait = "0.1"
|
async-trait = { version = "0.1", optional = true }
|
||||||
libc = "0.2"
|
libc = { version = "0.2", optional = true }
|
||||||
|
|
||||||
# Ventoy bootable image support
|
# Ventoy bootable image support
|
||||||
ventoy-img = { path = "libs/ventoy-img-rs" }
|
ventoy-img = { path = "libs/ventoy-img-rs", optional = true }
|
||||||
|
|
||||||
# RustDesk protocol support
|
# RustDesk protocol support
|
||||||
protobuf = { version = "3.7", features = ["with-bytes"] }
|
protobuf = { version = "3.7", features = ["with-bytes"], optional = true }
|
||||||
sodiumoxide = "0.2"
|
sodiumoxide = { version = "0.2", optional = true }
|
||||||
sha2 = "0.10"
|
sha2 = { version = "0.10", optional = true }
|
||||||
# TypeScript type generation
|
# TypeScript type generation
|
||||||
typeshare = "1.0"
|
typeshare = { version = "1.0", optional = true }
|
||||||
|
|
||||||
[target.'cfg(any(unix, windows))'.dependencies]
|
[target.'cfg(any(unix, windows))'.dependencies]
|
||||||
# Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux)
|
# Video encoding/decoding (FFmpeg/libjpeg-turbo/libyuv; available on Windows and Linux)
|
||||||
hwcodec = { path = "libs/hwcodec" }
|
hwcodec = { path = "libs/hwcodec", features = ["bytes"], optional = true }
|
||||||
libyuv = { path = "res/vcpkg/libyuv" }
|
libyuv = { path = "res/vcpkg/libyuv", optional = true }
|
||||||
turbojpeg = "1.3"
|
turbojpeg = { version = "1.3", optional = true }
|
||||||
# Note: audiopus links to libopus.so (unavoidable for audio support)
|
# Note: audiopus links to libopus.so (unavoidable for audio support)
|
||||||
audiopus = "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]
|
[target.'cfg(unix)'.dependencies]
|
||||||
# Video capture (V4L2)
|
# Video capture (V4L2)
|
||||||
v4l2r = "0.0.7"
|
v4l2r = { path = "libs/v4l2r", optional = true }
|
||||||
|
|
||||||
# Audio (ALSA capture)
|
# Audio (ALSA capture)
|
||||||
alsa = "0.11"
|
alsa = { version = "0.11", optional = true }
|
||||||
|
|
||||||
# ATX (GPIO control)
|
# ATX (GPIO control)
|
||||||
gpio-cdev = "0.6"
|
gpio-cdev = { version = "0.6", optional = true }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[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 = [
|
windows-sys = { version = "0.61", features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_NetworkManagement_IpHelper",
|
"Win32_NetworkManagement_IpHelper",
|
||||||
@@ -129,7 +255,7 @@ windows-sys = { version = "0.61", features = [
|
|||||||
"Win32_Networking_WinSock",
|
"Win32_Networking_WinSock",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
] }
|
], optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
7
android/.gitignore
vendored
Normal file
7
android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
app/build/
|
||||||
|
app/src/main/jniLibs/
|
||||||
|
native/target/
|
||||||
566
android/app/build.gradle.kts
Normal file
566
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import org.gradle.api.tasks.Exec
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
}
|
||||||
|
|
||||||
|
val androidNdkVersion = "27.3.13750724"
|
||||||
|
val androidApiLevel = 21
|
||||||
|
val nativeCrateDir = layout.projectDirectory.dir("../native")
|
||||||
|
val rootCrateDir = layout.projectDirectory.dir("../..")
|
||||||
|
val nativeCargoOutputDir = layout.buildDirectory.dir("generated/oneKvm/cargoJniLibs")
|
||||||
|
val nativeOutputRoot = layout.buildDirectory.dir("generated/oneKvm/jniLibs")
|
||||||
|
val nativeAssetRoot = layout.buildDirectory.dir("generated/oneKvm/assets")
|
||||||
|
val defaultAndroidFfmpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-ffmpeg-mediacodec")
|
||||||
|
val defaultAndroidLibyuvRoot = rootProject.layout.projectDirectory.dir("../dist/android-libyuv")
|
||||||
|
val defaultAndroidTurbojpegRoot = rootProject.layout.projectDirectory.dir("../dist/android-turbojpeg")
|
||||||
|
val defaultAndroidAlsaRoot = rootProject.layout.projectDirectory.dir("../dist/android-alsa")
|
||||||
|
val defaultAndroidOpusRoot = rootProject.layout.projectDirectory.dir("../dist/android-opus")
|
||||||
|
val androidFfmpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_FFMPEG_ROOT")
|
||||||
|
.orElse(defaultAndroidFfmpegRoot.asFile.absolutePath)
|
||||||
|
val androidLibyuvRoot = providers.environmentVariable("ONE_KVM_ANDROID_LIBYUV_ROOT")
|
||||||
|
.orElse(defaultAndroidLibyuvRoot.asFile.absolutePath)
|
||||||
|
val androidTurbojpegRoot = providers.environmentVariable("ONE_KVM_ANDROID_TURBOJPEG_ROOT")
|
||||||
|
.orElse(defaultAndroidTurbojpegRoot.asFile.absolutePath)
|
||||||
|
val androidAlsaRoot = providers.environmentVariable("ONE_KVM_ANDROID_ALSA_ROOT")
|
||||||
|
.orElse(defaultAndroidAlsaRoot.asFile.absolutePath)
|
||||||
|
val androidOpusRoot = providers.environmentVariable("ONE_KVM_ANDROID_OPUS_ROOT")
|
||||||
|
.orElse(defaultAndroidOpusRoot.asFile.absolutePath)
|
||||||
|
val selectedAndroidAbis = providers.environmentVariable("ONE_KVM_ANDROID_ABIS")
|
||||||
|
.orElse("arm64-v8a,armeabi-v7a")
|
||||||
|
.get()
|
||||||
|
.split(',', ' ', ';')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
val androidBuildProfile = providers.environmentVariable("ONE_KVM_ANDROID_PROFILE")
|
||||||
|
.orElse("debug")
|
||||||
|
.get()
|
||||||
|
.lowercase()
|
||||||
|
val androidFfmpegSourceDir = rootProject.layout.projectDirectory
|
||||||
|
.dir("../.tmp/android-ffmpeg-check/src/ffmpeg-rockchip")
|
||||||
|
val localProperties = Properties().apply {
|
||||||
|
val file = rootProject.file("local.properties")
|
||||||
|
if (file.exists()) {
|
||||||
|
file.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val androidSdkDir = file(
|
||||||
|
providers.environmentVariable("ANDROID_HOME")
|
||||||
|
.orElse(providers.environmentVariable("ANDROID_SDK_ROOT"))
|
||||||
|
.orElse(localProperties.getProperty("sdk.dir") ?: "/root/android-sdk")
|
||||||
|
.get(),
|
||||||
|
)
|
||||||
|
val androidNdkDir = androidSdkDir.resolve("ndk/$androidNdkVersion")
|
||||||
|
|
||||||
|
val androidFfmpegBuildScript = rootProject.layout.projectDirectory
|
||||||
|
.dir("..")
|
||||||
|
.file("scripts/build-android-ffmpeg-mediacodec.sh")
|
||||||
|
val androidLibyuvBuildScript = rootProject.layout.projectDirectory
|
||||||
|
.dir("..")
|
||||||
|
.file("scripts/build-android-libyuv.sh")
|
||||||
|
val androidTurbojpegBuildScript = rootProject.layout.projectDirectory
|
||||||
|
.dir("..")
|
||||||
|
.file("scripts/build-android-turbojpeg.sh")
|
||||||
|
val androidAlsaBuildScript = rootProject.layout.projectDirectory
|
||||||
|
.dir("..")
|
||||||
|
.file("scripts/build-android-alsa.sh")
|
||||||
|
val androidOpusBuildScript = rootProject.layout.projectDirectory
|
||||||
|
.dir("..")
|
||||||
|
.file("scripts/build-android-opus.sh")
|
||||||
|
|
||||||
|
val androidAbiTargets = mapOf(
|
||||||
|
"arm64-v8a" to Triple("arm64", "aarch64-linux-android", "aarch64-linux-android"),
|
||||||
|
"armeabi-v7a" to Triple("arm32", "armv7-linux-androideabi", "arm-linux-androideabi"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedAndroidAbiTargets = selectedAndroidAbis.associateWith { abi ->
|
||||||
|
androidAbiTargets[abi] ?: throw GradleException(
|
||||||
|
"Unsupported ONE_KVM_ANDROID_ABIS entry: $abi. Supported values: ${androidAbiTargets.keys.joinToString(", ")}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (androidBuildProfile != "debug" && androidBuildProfile != "release") {
|
||||||
|
throw GradleException("Unsupported ONE_KVM_ANDROID_PROFILE: $androidBuildProfile. Use debug or release.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidFfmpegBuildStamp(script: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(script.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidFfmpegRequiredFiles(root: File): List<File> = listOf(
|
||||||
|
"include/libavcodec/avcodec.h",
|
||||||
|
"lib/libavcodec.a",
|
||||||
|
"lib/libavutil.a",
|
||||||
|
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
|
||||||
|
|
||||||
|
fun androidLibyuvBuildStamp(script: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(script.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
val turbojpegScriptDigest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(androidTurbojpegBuildScript.asFile.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest;turbojpegScript=$turbojpegScriptDigest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidLibyuvRequiredFiles(root: File): List<File> = listOf(
|
||||||
|
"include/libyuv.h",
|
||||||
|
"lib/libyuv.a",
|
||||||
|
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
|
||||||
|
|
||||||
|
fun androidTurbojpegBuildStamp(script: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(script.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidAlsaBuildStamp(script: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(script.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidOpusBuildStamp(script: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(script.readBytes())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
return "api=$androidApiLevel;abis=${selectedAndroidAbis.joinToString(",")};script=$digest"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun androidTurbojpegRequiredFiles(root: File): List<File> = listOf(
|
||||||
|
"include/turbojpeg.h",
|
||||||
|
"include/jpeglib.h",
|
||||||
|
"lib/libjpeg.a",
|
||||||
|
"lib/libturbojpeg.a",
|
||||||
|
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
|
||||||
|
|
||||||
|
fun androidAlsaRequiredFiles(root: File): List<File> = listOf(
|
||||||
|
"include/alsa/asoundlib.h",
|
||||||
|
"lib/libasound.so",
|
||||||
|
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
|
||||||
|
|
||||||
|
fun androidOpusRequiredFiles(root: File): List<File> = listOf(
|
||||||
|
"include/opus/opus.h",
|
||||||
|
"lib/libopus.so",
|
||||||
|
).flatMap { path -> selectedAndroidAbis.map { abi -> root.resolve("$abi/$path") } }
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "cn.one_kvm.androidhost"
|
||||||
|
compileSdk = 36
|
||||||
|
ndkVersion = androidNdkVersion
|
||||||
|
flavorDimensions += "abi"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "cn.one_kvm.androidhost"
|
||||||
|
minSdk = androidApiLevel
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("arm32") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += "armeabi-v7a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create("arm64") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += "arm64-v8a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
assets.directories.clear()
|
||||||
|
jniLibs.directories.clear()
|
||||||
|
}
|
||||||
|
getByName("arm32") {
|
||||||
|
assets.directories.add("build/generated/oneKvm/assets/arm32")
|
||||||
|
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm32")
|
||||||
|
}
|
||||||
|
getByName("arm64") {
|
||||||
|
assets.directories.add("build/generated/oneKvm/assets/arm64")
|
||||||
|
jniLibs.directories.add("build/generated/oneKvm/jniLibs/arm64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("buildAndroidFfmpegMediaCodec") {
|
||||||
|
description = "Builds the default Android FFmpeg MediaCodec static libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
val ffmpegRoot = file(androidFfmpegRoot.get())
|
||||||
|
val sourceDir = androidFfmpegSourceDir.asFile
|
||||||
|
val scriptFile = androidFfmpegBuildScript.asFile
|
||||||
|
val stampFile = ffmpegRoot.resolve(".one-kvm-android-ffmpeg.stamp")
|
||||||
|
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
|
||||||
|
commandLine(
|
||||||
|
"bash",
|
||||||
|
scriptFile.absolutePath,
|
||||||
|
"--source",
|
||||||
|
sourceDir.absolutePath,
|
||||||
|
"--output",
|
||||||
|
ffmpegRoot.absolutePath,
|
||||||
|
"--ndk",
|
||||||
|
androidNdkDir.absolutePath,
|
||||||
|
"--api",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"--abis",
|
||||||
|
selectedAndroidAbis.joinToString(","),
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs.dir(sourceDir)
|
||||||
|
inputs.file(scriptFile)
|
||||||
|
outputs.dir(ffmpegRoot)
|
||||||
|
|
||||||
|
onlyIf {
|
||||||
|
val hasAndroidFfmpeg = androidFfmpegRequiredFiles(ffmpegRoot).all { it.exists() }
|
||||||
|
val hasCurrentBuildStamp =
|
||||||
|
stampFile.exists() && stampFile.readText() == androidFfmpegBuildStamp(scriptFile)
|
||||||
|
if (!hasAndroidFfmpeg && !sourceDir.resolve("configure").exists()) {
|
||||||
|
throw GradleException(
|
||||||
|
"Missing Android FFmpeg MediaCodec build at ${ffmpegRoot.absolutePath}, " +
|
||||||
|
"and source was not found at ${sourceDir.absolutePath}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
!hasAndroidFfmpeg || !hasCurrentBuildStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
stampFile.writeText(androidFfmpegBuildStamp(scriptFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("buildAndroidLibyuv") {
|
||||||
|
description = "Builds Android libyuv static libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
val libyuvRoot = file(androidLibyuvRoot.get())
|
||||||
|
val turbojpegRoot = file(androidTurbojpegRoot.get())
|
||||||
|
val scriptFile = androidLibyuvBuildScript.asFile
|
||||||
|
val stampFile = libyuvRoot.resolve(".one-kvm-android-libyuv.stamp")
|
||||||
|
|
||||||
|
dependsOn("buildAndroidTurbojpeg")
|
||||||
|
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
|
||||||
|
commandLine(
|
||||||
|
"bash",
|
||||||
|
scriptFile.absolutePath,
|
||||||
|
"--output",
|
||||||
|
libyuvRoot.absolutePath,
|
||||||
|
"--ndk",
|
||||||
|
androidNdkDir.absolutePath,
|
||||||
|
"--api",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"--abis",
|
||||||
|
selectedAndroidAbis.joinToString(","),
|
||||||
|
"--jpeg-root",
|
||||||
|
turbojpegRoot.absolutePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs.file(scriptFile)
|
||||||
|
outputs.dir(libyuvRoot)
|
||||||
|
|
||||||
|
onlyIf {
|
||||||
|
val hasAndroidLibyuv = androidLibyuvRequiredFiles(libyuvRoot).all { it.exists() }
|
||||||
|
val hasCurrentBuildStamp =
|
||||||
|
stampFile.exists() && stampFile.readText() == androidLibyuvBuildStamp(scriptFile)
|
||||||
|
!hasAndroidLibyuv || !hasCurrentBuildStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
stampFile.writeText(androidLibyuvBuildStamp(scriptFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("buildAndroidTurbojpeg") {
|
||||||
|
description = "Builds Android TurboJPEG static libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
val turbojpegRoot = file(androidTurbojpegRoot.get())
|
||||||
|
val scriptFile = androidTurbojpegBuildScript.asFile
|
||||||
|
val stampFile = turbojpegRoot.resolve(".one-kvm-android-turbojpeg.stamp")
|
||||||
|
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
|
||||||
|
commandLine(
|
||||||
|
"bash",
|
||||||
|
scriptFile.absolutePath,
|
||||||
|
"--output",
|
||||||
|
turbojpegRoot.absolutePath,
|
||||||
|
"--ndk",
|
||||||
|
androidNdkDir.absolutePath,
|
||||||
|
"--api",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"--abis",
|
||||||
|
selectedAndroidAbis.joinToString(","),
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs.file(scriptFile)
|
||||||
|
outputs.dir(turbojpegRoot)
|
||||||
|
|
||||||
|
onlyIf {
|
||||||
|
val hasAndroidTurbojpeg = androidTurbojpegRequiredFiles(turbojpegRoot).all { it.exists() }
|
||||||
|
val hasCurrentBuildStamp =
|
||||||
|
stampFile.exists() && stampFile.readText() == androidTurbojpegBuildStamp(scriptFile)
|
||||||
|
!hasAndroidTurbojpeg || !hasCurrentBuildStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
stampFile.writeText(androidTurbojpegBuildStamp(scriptFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("buildAndroidAlsa") {
|
||||||
|
description = "Builds Android ALSA shared libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
val alsaRoot = file(androidAlsaRoot.get())
|
||||||
|
val scriptFile = androidAlsaBuildScript.asFile
|
||||||
|
val stampFile = alsaRoot.resolve(".one-kvm-android-alsa.stamp")
|
||||||
|
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
|
||||||
|
commandLine(
|
||||||
|
"bash",
|
||||||
|
scriptFile.absolutePath,
|
||||||
|
"--output",
|
||||||
|
alsaRoot.absolutePath,
|
||||||
|
"--ndk",
|
||||||
|
androidNdkDir.absolutePath,
|
||||||
|
"--api",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"--abis",
|
||||||
|
selectedAndroidAbis.joinToString(","),
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs.file(scriptFile)
|
||||||
|
outputs.dir(alsaRoot)
|
||||||
|
|
||||||
|
onlyIf {
|
||||||
|
val hasAndroidAlsa = androidAlsaRequiredFiles(alsaRoot).all { it.exists() }
|
||||||
|
val hasCurrentBuildStamp =
|
||||||
|
stampFile.exists() && stampFile.readText() == androidAlsaBuildStamp(scriptFile)
|
||||||
|
!hasAndroidAlsa || !hasCurrentBuildStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
stampFile.writeText(androidAlsaBuildStamp(scriptFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("buildAndroidOpus") {
|
||||||
|
description = "Builds Android Opus shared libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
val opusRoot = file(androidOpusRoot.get())
|
||||||
|
val scriptFile = androidOpusBuildScript.asFile
|
||||||
|
val stampFile = opusRoot.resolve(".one-kvm-android-opus.stamp")
|
||||||
|
|
||||||
|
workingDir = rootProject.layout.projectDirectory.dir("..").asFile
|
||||||
|
commandLine(
|
||||||
|
"bash",
|
||||||
|
scriptFile.absolutePath,
|
||||||
|
"--output",
|
||||||
|
opusRoot.absolutePath,
|
||||||
|
"--ndk",
|
||||||
|
androidNdkDir.absolutePath,
|
||||||
|
"--api",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"--abis",
|
||||||
|
selectedAndroidAbis.joinToString(","),
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs.file(scriptFile)
|
||||||
|
outputs.dir(opusRoot)
|
||||||
|
|
||||||
|
onlyIf {
|
||||||
|
val hasAndroidOpus = androidOpusRequiredFiles(opusRoot).all { it.exists() }
|
||||||
|
val hasCurrentBuildStamp =
|
||||||
|
stampFile.exists() && stampFile.readText() == androidOpusBuildStamp(scriptFile)
|
||||||
|
!hasAndroidOpus || !hasCurrentBuildStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
stampFile.writeText(androidOpusBuildStamp(scriptFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cargoBuildAndroidAbiTaskNames = selectedAndroidAbiTargets.map { (abi, targets) ->
|
||||||
|
val (flavor, _, _) = targets
|
||||||
|
val taskName = "cargoBuildAndroid" + flavor.replaceFirstChar {
|
||||||
|
if (it.isLowerCase()) it.titlecase() else it.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>(taskName) {
|
||||||
|
description = "Builds the Android Rust bootstrap libraries for $abi."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
dependsOn(
|
||||||
|
"buildAndroidFfmpegMediaCodec",
|
||||||
|
"buildAndroidLibyuv",
|
||||||
|
"buildAndroidTurbojpeg",
|
||||||
|
"buildAndroidAlsa",
|
||||||
|
"buildAndroidOpus",
|
||||||
|
)
|
||||||
|
|
||||||
|
val cargoCommand = mutableListOf(
|
||||||
|
"cargo",
|
||||||
|
"ndk",
|
||||||
|
"-t",
|
||||||
|
abi,
|
||||||
|
"-P",
|
||||||
|
androidApiLevel.toString(),
|
||||||
|
"-o",
|
||||||
|
nativeCargoOutputDir.get().asFile.absolutePath,
|
||||||
|
"build",
|
||||||
|
"--lib",
|
||||||
|
"--bins",
|
||||||
|
)
|
||||||
|
if (androidBuildProfile == "release") {
|
||||||
|
cargoCommand.add("--release")
|
||||||
|
}
|
||||||
|
|
||||||
|
workingDir = nativeCrateDir.asFile
|
||||||
|
commandLine(cargoCommand)
|
||||||
|
args("--features", "android-mediacodec")
|
||||||
|
environment("ONE_KVM_ANDROID_FFMPEG_ROOT", androidFfmpegRoot.get())
|
||||||
|
environment("ONE_KVM_ANDROID_LIBYUV_ROOT", androidLibyuvRoot.get())
|
||||||
|
environment("ONE_KVM_ANDROID_LIBYUV_STATIC", "1")
|
||||||
|
environment("TURBOJPEG_SOURCE", "explicit")
|
||||||
|
environment("TURBOJPEG_STATIC", "1")
|
||||||
|
environment(
|
||||||
|
"TURBOJPEG_LIB_DIR",
|
||||||
|
file(androidTurbojpegRoot.get()).resolve("$abi/lib").absolutePath,
|
||||||
|
)
|
||||||
|
environment(
|
||||||
|
"TURBOJPEG_INCLUDE_DIR",
|
||||||
|
file(androidTurbojpegRoot.get()).resolve("$abi/include").absolutePath,
|
||||||
|
)
|
||||||
|
environment("PKG_CONFIG_ALLOW_CROSS", "1")
|
||||||
|
environment(
|
||||||
|
"PKG_CONFIG_LIBDIR",
|
||||||
|
file(androidAlsaRoot.get()).resolve("$abi/lib/pkgconfig").absolutePath,
|
||||||
|
)
|
||||||
|
environment("PKG_CONFIG_SYSROOT_DIR", "")
|
||||||
|
environment("LIBOPUS_NO_PKG", "1")
|
||||||
|
environment("LIBOPUS_LIB_DIR", file(androidOpusRoot.get()).resolve("$abi/lib").absolutePath)
|
||||||
|
environment("ANDROID_HOME", androidSdkDir.absolutePath)
|
||||||
|
environment("ANDROID_SDK_ROOT", androidSdkDir.absolutePath)
|
||||||
|
environment("ANDROID_NDK_HOME", androidNdkDir.absolutePath)
|
||||||
|
environment("ANDROID_NDK", androidNdkDir.absolutePath)
|
||||||
|
environment("ANDROID_NDK_ROOT", androidNdkDir.absolutePath)
|
||||||
|
|
||||||
|
inputs.files(
|
||||||
|
nativeCrateDir.file("Cargo.toml"),
|
||||||
|
nativeCrateDir.dir("src"),
|
||||||
|
rootCrateDir.file("Cargo.lock"),
|
||||||
|
rootCrateDir.file("Cargo.toml"),
|
||||||
|
rootCrateDir.file("build.rs"),
|
||||||
|
rootCrateDir.dir("libs"),
|
||||||
|
rootCrateDir.dir("res/vcpkg/libyuv"),
|
||||||
|
rootCrateDir.dir("src"),
|
||||||
|
)
|
||||||
|
outputs.dir(nativeCargoOutputDir)
|
||||||
|
outputs.dir(file(androidFfmpegRoot.get()))
|
||||||
|
outputs.dir(file(androidLibyuvRoot.get()))
|
||||||
|
outputs.dir(file(androidTurbojpegRoot.get()))
|
||||||
|
outputs.dir(file(androidAlsaRoot.get()))
|
||||||
|
outputs.dir(file(androidOpusRoot.get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
taskName
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("cargoBuildAndroid") {
|
||||||
|
description = "Builds the Android Rust bootstrap libraries."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
dependsOn(cargoBuildAndroidAbiTaskNames)
|
||||||
|
|
||||||
|
outputs.dir(nativeOutputRoot)
|
||||||
|
outputs.dir(nativeAssetRoot)
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
selectedAndroidAbiTargets.forEach { (abi, targets) ->
|
||||||
|
val (flavor, rustTriple, ndkTriple) = targets
|
||||||
|
val nativeLibSource = nativeCargoOutputDir.get().file("$abi/libone_kvm_android_bootstrap.so").asFile
|
||||||
|
if (!nativeLibSource.exists()) {
|
||||||
|
throw GradleException("Missing Android JNI library: ${nativeLibSource.absolutePath}")
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(nativeLibSource)
|
||||||
|
into(nativeOutputRoot.get().dir(flavor).dir(abi))
|
||||||
|
}
|
||||||
|
|
||||||
|
val source = nativeCrateDir.file("target/$rustTriple/$androidBuildProfile/one-kvm-android-host").asFile
|
||||||
|
if (!source.exists()) {
|
||||||
|
throw GradleException("Missing Android host binary: ${source.absolutePath}")
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(source)
|
||||||
|
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
|
||||||
|
rename { "one-kvm-android-host" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val cxxShared = androidNdkDir
|
||||||
|
.resolve("toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/$ndkTriple/libc++_shared.so")
|
||||||
|
if (!cxxShared.exists()) {
|
||||||
|
throw GradleException("Missing NDK libc++_shared.so: ${cxxShared.absolutePath}")
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(cxxShared)
|
||||||
|
into(nativeOutputRoot.get().dir(flavor).dir(abi))
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(cxxShared)
|
||||||
|
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val alsaShared = file(androidAlsaRoot.get()).resolve("$abi/lib/libasound.so")
|
||||||
|
if (!alsaShared.exists()) {
|
||||||
|
throw GradleException("Missing Android ALSA library: ${alsaShared.absolutePath}")
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(alsaShared)
|
||||||
|
into(nativeOutputRoot.get().dir(flavor).dir(abi))
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(alsaShared)
|
||||||
|
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(file(androidAlsaRoot.get()).resolve("$abi/share/alsa"))
|
||||||
|
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi/alsa"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val opusShared = file(androidOpusRoot.get()).resolve("$abi/lib/libopus.so")
|
||||||
|
if (!opusShared.exists()) {
|
||||||
|
throw GradleException("Missing Android Opus library: ${opusShared.absolutePath}")
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(opusShared)
|
||||||
|
into(nativeOutputRoot.get().dir(flavor).dir(abi))
|
||||||
|
}
|
||||||
|
copy {
|
||||||
|
from(opusShared)
|
||||||
|
into(nativeAssetRoot.get().dir(flavor).dir("bin/$abi"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("preBuild") {
|
||||||
|
dependsOn("cargoBuildAndroid")
|
||||||
|
}
|
||||||
36
android/app/src/main/AndroidManifest.xml
Normal file
36
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@drawable/ic_launcher_one_kvm"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
<service
|
||||||
|
android:name=".OneKvmService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_BOOT_COMPLETED && HostSettings.getAutoStart(context)) {
|
||||||
|
OneKvmService.start(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object HostSettings {
|
||||||
|
private const val PREFS = "one_kvm_android"
|
||||||
|
private const val KEY_AUTO_START = "auto_start"
|
||||||
|
private const val KEY_CLEAR_EXISTING_OTG = "clear_existing_otg"
|
||||||
|
|
||||||
|
fun getAutoStart(context: Context): Boolean {
|
||||||
|
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(KEY_AUTO_START, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoStart(context: Context, enabled: Boolean) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean(KEY_AUTO_START, enabled)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getClearExistingOtg(context: Context): Boolean {
|
||||||
|
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(KEY_CLEAR_EXISTING_OTG, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setClearExistingOtg(context: Context, enabled: Boolean) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean(KEY_CLEAR_EXISTING_OTG, enabled)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object LogConfig {
|
||||||
|
private const val PREFS = "one_kvm_android"
|
||||||
|
private const val KEY_LOG_LEVEL = "log_level"
|
||||||
|
const val DEFAULT_LEVEL = "info"
|
||||||
|
val LEVELS = arrayOf("error", "warn", "info", "debug", "trace")
|
||||||
|
|
||||||
|
fun getLevel(context: Context): String {
|
||||||
|
val value = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_LOG_LEVEL, DEFAULT_LEVEL)
|
||||||
|
?: DEFAULT_LEVEL
|
||||||
|
return if (LEVELS.contains(value)) value else DEFAULT_LEVEL
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLevel(context: Context, level: String) {
|
||||||
|
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_LOG_LEVEL, safeLevel)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rustLogFilter(level: String): String {
|
||||||
|
val safeLevel = if (LEVELS.contains(level)) level else DEFAULT_LEVEL
|
||||||
|
return "one_kvm=$safeLevel,hwcodec=$safeLevel,tower_http=$safeLevel,webrtc_sctp=warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt
Normal file
71
android/app/src/main/java/cn/one_kvm/androidhost/LogStore.kt
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object LogStore {
|
||||||
|
private const val FLUSH_DELAY_MS = 250L
|
||||||
|
private const val MAX_BUFFER_CHARS = 64 * 1024
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val buffer = StringBuilder()
|
||||||
|
private val executor = Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||||
|
Thread(runnable, "OneKvmLogStore").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var logFile: File? = null
|
||||||
|
private var flushScheduled = false
|
||||||
|
|
||||||
|
fun defaultLogFile(context: Context): File {
|
||||||
|
return File(File(context.getExternalFilesDir(null), "runtime"), "one-kvm.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configure(file: File) {
|
||||||
|
synchronized(lock) {
|
||||||
|
flushLocked()
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeText("")
|
||||||
|
buffer.clear()
|
||||||
|
logFile = file
|
||||||
|
flushScheduled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun append(line: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (logFile == null) return
|
||||||
|
|
||||||
|
buffer.append(line).append('\n')
|
||||||
|
if (buffer.length >= MAX_BUFFER_CHARS) {
|
||||||
|
flushLocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flushScheduled) {
|
||||||
|
flushScheduled = true
|
||||||
|
executor.schedule({ flush() }, FLUSH_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
synchronized(lock) {
|
||||||
|
flushLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushLocked() {
|
||||||
|
val file = logFile ?: return
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
flushScheduled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val text = buffer.toString()
|
||||||
|
buffer.clear()
|
||||||
|
flushScheduled = false
|
||||||
|
file.appendText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
452
android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt
Normal file
452
android/app/src/main/java/cn/one_kvm/androidhost/MainActivity.kt
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.CompoundButton
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.Spinner
|
||||||
|
import android.widget.Switch
|
||||||
|
import android.widget.TextView
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class MainActivity : Activity() {
|
||||||
|
private lateinit var statusValue: TextView
|
||||||
|
private lateinit var hostActionButton: Button
|
||||||
|
private lateinit var logLevelSpinner: Spinner
|
||||||
|
private lateinit var autoStartSwitch: Switch
|
||||||
|
private lateinit var clearOtgSwitch: Switch
|
||||||
|
private val statusHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var statusPollsRemaining = 0
|
||||||
|
private val statusPoller = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
refreshStatus()
|
||||||
|
statusPollsRemaining -= 1
|
||||||
|
if (statusPollsRemaining > 0) {
|
||||||
|
statusHandler.postDelayed(this, STATUS_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
window.statusBarColor = color("#F8FAFC")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
window.navigationBarColor = color("#F8FAFC")
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(20.dp(), 24.dp(), 20.dp(), 28.dp())
|
||||||
|
background = solid("#F8FAFC")
|
||||||
|
}
|
||||||
|
|
||||||
|
content.addView(startCard())
|
||||||
|
content.addView(settingsCard())
|
||||||
|
content.addView(infoCard())
|
||||||
|
|
||||||
|
setContentView(ScrollView(this).apply {
|
||||||
|
isFillViewport = true
|
||||||
|
setBackgroundColor(color("#F8FAFC"))
|
||||||
|
addView(content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
reconcilePersistedStatus()
|
||||||
|
refreshStatus()
|
||||||
|
autoStartSwitch.isChecked = HostSettings.getAutoStart(this)
|
||||||
|
clearOtgSwitch.isChecked = HostSettings.getClearExistingOtg(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
statusHandler.removeCallbacks(statusPoller)
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCard(): View {
|
||||||
|
return card {
|
||||||
|
addView(sectionTitle("启动管理"))
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "管理本机 One-KVM 服务进程。暂停会停止前台服务并释放运行资源。"
|
||||||
|
textSize = 14f
|
||||||
|
setTextColor(color("#64748B"))
|
||||||
|
setPadding(0, 6.dp(), 0, 14.dp())
|
||||||
|
})
|
||||||
|
|
||||||
|
statusValue = TextView(this@MainActivity).apply {
|
||||||
|
textSize = 14f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(color("#0F172A"))
|
||||||
|
background = rounded("#EFF6FF", "#BFDBFE", 8)
|
||||||
|
setPadding(12.dp(), 8.dp(), 12.dp(), 8.dp())
|
||||||
|
}
|
||||||
|
addView(statusValue, matchWrap())
|
||||||
|
|
||||||
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(0, 14.dp(), 0, 0)
|
||||||
|
hostActionButton = actionButton("启动", primary = true) { toggleHost() }
|
||||||
|
addView(hostActionButton, matchButton())
|
||||||
|
})
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun settingsCard(): View {
|
||||||
|
return card {
|
||||||
|
addView(sectionTitle("运行设置"))
|
||||||
|
|
||||||
|
val (autoStartRow, autoStartControl) = settingSwitchRow(
|
||||||
|
title = "开机自启动",
|
||||||
|
subtitle = "系统启动完成后自动拉起 One-KVM 前台服务。",
|
||||||
|
checked = HostSettings.getAutoStart(this@MainActivity),
|
||||||
|
) { _, checked ->
|
||||||
|
HostSettings.setAutoStart(this@MainActivity, checked)
|
||||||
|
LogStore.append("Boot auto-start ${if (checked) "enabled" else "disabled"}")
|
||||||
|
}
|
||||||
|
autoStartSwitch = autoStartControl
|
||||||
|
addView(autoStartRow)
|
||||||
|
|
||||||
|
addView(divider())
|
||||||
|
|
||||||
|
val (clearOtgRow, clearOtgControl) = settingSwitchRow(
|
||||||
|
title = "清除已有 OTG Gadget",
|
||||||
|
subtitle = "启动 root host 前尝试解绑并删除 configfs 中已有的 USB gadget。",
|
||||||
|
checked = HostSettings.getClearExistingOtg(this@MainActivity),
|
||||||
|
) { _, checked ->
|
||||||
|
HostSettings.setClearExistingOtg(this@MainActivity, checked)
|
||||||
|
LogStore.append("Clear existing OTG gadget ${if (checked) "enabled" else "disabled"}")
|
||||||
|
}
|
||||||
|
clearOtgSwitch = clearOtgControl
|
||||||
|
addView(clearOtgRow)
|
||||||
|
|
||||||
|
addView(divider())
|
||||||
|
addView(logLevelRow())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun infoCard(): View {
|
||||||
|
return card {
|
||||||
|
addView(sectionTitle("应用信息"))
|
||||||
|
addView(infoRow("软件内核版本", kernelVersion()))
|
||||||
|
addView(infoRow("访问地址", accessAddresses(), selectable = true))
|
||||||
|
addView(infoRow("日志文件", LogStore.defaultLogFile(this@MainActivity).absolutePath, selectable = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun settingSwitchRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
checked: Boolean,
|
||||||
|
listener: CompoundButton.OnCheckedChangeListener,
|
||||||
|
): Pair<View, Switch> {
|
||||||
|
val switch = Switch(this).apply {
|
||||||
|
isChecked = checked
|
||||||
|
setOnCheckedChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
val row = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(0, 12.dp(), 0, 12.dp())
|
||||||
|
addView(LinearLayout(this@MainActivity).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = title
|
||||||
|
textSize = 15f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(color("#0F172A"))
|
||||||
|
})
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = subtitle
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(color("#64748B"))
|
||||||
|
setPadding(0, 4.dp(), 12.dp(), 0)
|
||||||
|
})
|
||||||
|
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
|
||||||
|
addView(switch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return row to switch
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun infoRow(label: String, value: String, selectable: Boolean = false): View {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(0, 12.dp(), 0, 12.dp())
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = label
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(color("#64748B"))
|
||||||
|
})
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = value
|
||||||
|
textSize = 15f
|
||||||
|
setTextColor(color("#0F172A"))
|
||||||
|
setPadding(0, 4.dp(), 0, 0)
|
||||||
|
setTextIsSelectable(selectable)
|
||||||
|
})
|
||||||
|
addView(divider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logLevelRow(): View {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(0, 12.dp(), 0, 0)
|
||||||
|
addView(TextView(this@MainActivity).apply {
|
||||||
|
text = "日志级别"
|
||||||
|
textSize = 15f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(color("#0F172A"))
|
||||||
|
}, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
|
||||||
|
|
||||||
|
logLevelSpinner = Spinner(this@MainActivity).apply {
|
||||||
|
adapter = ArrayAdapter(
|
||||||
|
this@MainActivity,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
LogConfig.LEVELS,
|
||||||
|
)
|
||||||
|
setSelection(LogConfig.LEVELS.indexOf(LogConfig.getLevel(this@MainActivity)).coerceAtLeast(0))
|
||||||
|
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
val level = LogConfig.LEVELS[position]
|
||||||
|
if (level != LogConfig.getLevel(this@MainActivity)) {
|
||||||
|
LogConfig.setLevel(this@MainActivity, level)
|
||||||
|
LogStore.append("Log level set to $level; restart service to apply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addView(logLevelSpinner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun card(build: LinearLayout.() -> Unit): View {
|
||||||
|
return LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(16.dp(), 16.dp(), 16.dp(), 16.dp())
|
||||||
|
background = rounded("#FFFFFF", "#E2E8F0", 10)
|
||||||
|
elevation = 1.5f.dpFloat()
|
||||||
|
build()
|
||||||
|
}.also {
|
||||||
|
it.layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
).apply { setMargins(0, 0, 0, 14.dp()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionTitle(text: String): View {
|
||||||
|
return TextView(this).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 17f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setTextColor(color("#0F172A"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun actionButton(text: String, primary: Boolean, action: () -> Unit): Button {
|
||||||
|
return Button(this).apply {
|
||||||
|
this.text = text
|
||||||
|
textSize = 15f
|
||||||
|
isAllCaps = false
|
||||||
|
minHeight = 44.dp()
|
||||||
|
setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
|
||||||
|
background = if (primary) rounded("#2563EB", "#2563EB", 8) else rounded("#FFFFFF", "#CBD5E1", 8)
|
||||||
|
setOnClickListener { action() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleHost() {
|
||||||
|
when (ServiceStatusStore.snapshot(this).state) {
|
||||||
|
ServiceStatusStore.STATE_RUNNING -> pauseHost()
|
||||||
|
ServiceStatusStore.STATE_STOPPED, ServiceStatusStore.STATE_ERROR -> startHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startHost() {
|
||||||
|
ServiceStatusStore.setStarting(this)
|
||||||
|
refreshStatus()
|
||||||
|
OneKvmService.start(this)
|
||||||
|
LogStore.append("Start requested from app UI")
|
||||||
|
pollStatusForAWhile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pauseHost() {
|
||||||
|
ServiceStatusStore.setStopping(this)
|
||||||
|
refreshStatus()
|
||||||
|
OneKvmService.stop(this)
|
||||||
|
LogStore.append("Pause requested from app UI")
|
||||||
|
pollStatusForAWhile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshStatus() {
|
||||||
|
if (::statusValue.isInitialized) {
|
||||||
|
statusValue.text = "状态:${hostStatusSummary()}"
|
||||||
|
}
|
||||||
|
updateHostActionButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hostStatusSummary(): String {
|
||||||
|
val serviceStatus = ServiceStatusStore.snapshot(this)
|
||||||
|
if (serviceStatus.state != ServiceStatusStore.STATE_STOPPED) {
|
||||||
|
return serviceStatus.labelText()
|
||||||
|
}
|
||||||
|
|
||||||
|
val nativeRunning = runCatching {
|
||||||
|
NativeBridge.hostStatus().contains("running", ignoreCase = true)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
return if (nativeRunning) "运行中" else "已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconcilePersistedStatus() {
|
||||||
|
val serviceStatus = ServiceStatusStore.snapshot(this)
|
||||||
|
if (serviceStatus.state == ServiceStatusStore.STATE_STOPPED) return
|
||||||
|
if (
|
||||||
|
serviceStatus.state == ServiceStatusStore.STATE_STARTING &&
|
||||||
|
System.currentTimeMillis() - serviceStatus.updatedAt < STARTING_RECONCILE_GRACE_MS
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
val portOpen = isLocalWebPortOpen()
|
||||||
|
val nativeRunning = runCatching { NativeBridge.hostStatus().contains("running", ignoreCase = true) }
|
||||||
|
.getOrDefault(false)
|
||||||
|
if (!portOpen && !nativeRunning) {
|
||||||
|
ServiceStatusStore.setStopped(this, "服务未运行")
|
||||||
|
runOnUiThread { refreshStatus() }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLocalWebPortOpen(): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
Socket().use { socket ->
|
||||||
|
socket.connect(InetSocketAddress("127.0.0.1", 8080), 250)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateHostActionButton() {
|
||||||
|
if (!::hostActionButton.isInitialized) return
|
||||||
|
|
||||||
|
when (ServiceStatusStore.snapshot(this).state) {
|
||||||
|
ServiceStatusStore.STATE_STARTING -> setHostActionButton("启动中...", enabled = false, primary = true)
|
||||||
|
ServiceStatusStore.STATE_RUNNING -> setHostActionButton("停止", enabled = true, primary = false)
|
||||||
|
ServiceStatusStore.STATE_STOPPING -> setHostActionButton("停止中...", enabled = false, primary = false)
|
||||||
|
else -> setHostActionButton("启动", enabled = true, primary = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setHostActionButton(text: String, enabled: Boolean, primary: Boolean) {
|
||||||
|
hostActionButton.text = text
|
||||||
|
hostActionButton.isEnabled = enabled
|
||||||
|
hostActionButton.alpha = if (enabled) 1f else 0.65f
|
||||||
|
hostActionButton.setTextColor(color(if (primary) "#FFFFFF" else "#0F172A"))
|
||||||
|
hostActionButton.background = if (primary) {
|
||||||
|
rounded("#2563EB", "#2563EB", 8)
|
||||||
|
} else {
|
||||||
|
rounded("#FFFFFF", "#CBD5E1", 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollStatusForAWhile() {
|
||||||
|
statusPollsRemaining = 20
|
||||||
|
statusHandler.removeCallbacks(statusPoller)
|
||||||
|
statusHandler.postDelayed(statusPoller, STATUS_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun kernelVersion(): String {
|
||||||
|
return runCatching { NativeBridge.kernelVersion() }
|
||||||
|
.getOrElse { "unknown" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accessAddresses(): String {
|
||||||
|
val addresses = runCatching {
|
||||||
|
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||||
|
.filter { it.isUp && !it.isLoopback }
|
||||||
|
.flatMap { iface -> Collections.list(iface.inetAddresses) }
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.filter { !it.isLoopbackAddress }
|
||||||
|
.map { "http://${it.hostAddress}:8080" }
|
||||||
|
.distinct()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
return (addresses.ifEmpty { listOf("http://127.0.0.1:8080") }).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun divider(): View {
|
||||||
|
return View(this).apply {
|
||||||
|
setBackgroundColor(color("#E2E8F0"))
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
1,
|
||||||
|
).apply { setMargins(0, 0, 0, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchWrap(): LinearLayout.LayoutParams {
|
||||||
|
return LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchButton(): LinearLayout.LayoutParams {
|
||||||
|
return LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
48.dp(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun solid(hex: String): GradientDrawable = GradientDrawable().apply {
|
||||||
|
setColor(color(hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rounded(fill: String, stroke: String, radiusDp: Int): GradientDrawable {
|
||||||
|
return GradientDrawable().apply {
|
||||||
|
setColor(color(fill))
|
||||||
|
cornerRadius = radiusDp.dpFloat()
|
||||||
|
setStroke(1.dp(), color(stroke))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun color(hex: String): Int = Color.parseColor(hex)
|
||||||
|
|
||||||
|
private fun Int.dp(): Int = (this * resources.displayMetrics.density + 0.5f).toInt()
|
||||||
|
|
||||||
|
private fun Int.dpFloat(): Float = this * resources.displayMetrics.density
|
||||||
|
|
||||||
|
private fun Float.dpFloat(): Float = this * resources.displayMetrics.density
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STATUS_POLL_INTERVAL_MS = 500L
|
||||||
|
private const val STARTING_RECONCILE_GRACE_MS = 15_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object NativeBridge {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("one_kvm_android_bootstrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun initTlsVerifier(context: Context): Int
|
||||||
|
|
||||||
|
external fun setEnv(name: String, value: String): Int
|
||||||
|
|
||||||
|
external fun startHost(dataDir: String, bindAddress: String, port: Int): String
|
||||||
|
|
||||||
|
external fun stopHost(): String
|
||||||
|
|
||||||
|
external fun hostStatus(): String
|
||||||
|
|
||||||
|
external fun kernelVersion(): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class OneKvmService : Service() {
|
||||||
|
private var rootProcess: Process? = null
|
||||||
|
private val commandExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
Thread(runnable, "OneKvmServiceCommand")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ensureNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action ?: ACTION_START) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
ServiceStatusStore.setStopping(this)
|
||||||
|
commandExecutor.execute {
|
||||||
|
stopHostRuntime()
|
||||||
|
stopSelfResult(startId)
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_START -> {
|
||||||
|
ServiceStatusStore.setStarting(this)
|
||||||
|
startForegroundCompat(NOTIFICATION_ID, notification("启动中"))
|
||||||
|
commandExecutor.execute {
|
||||||
|
val currentState = ServiceStatusStore.snapshot(this).state
|
||||||
|
if (currentState == ServiceStatusStore.STATE_RUNNING && isPortOpen(8080, 100)) {
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
val dataDir = File(getExternalFilesDir(null), "runtime")
|
||||||
|
if (!dataDir.exists()) dataDir.mkdirs()
|
||||||
|
val result = startRustHost(dataDir)
|
||||||
|
if (result.startsWith("Running") && !result.contains("start failed", ignoreCase = true)) {
|
||||||
|
ServiceStatusStore.setRunning(this, "服务已启动")
|
||||||
|
notificationManager().notify(NOTIFICATION_ID, notification("运行中"))
|
||||||
|
} else {
|
||||||
|
ServiceStatusStore.setError(this, "启动失败")
|
||||||
|
notificationManager().notify(NOTIFICATION_ID, notification("启动失败"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopHostRuntime(updateNotification = false)
|
||||||
|
commandExecutor.shutdownNow()
|
||||||
|
ServiceStatusStore.setStopped(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun notification(state: String): Notification {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
val pendingIntent = createContentIntent(intent)
|
||||||
|
val builder = createNotificationBuilder()
|
||||||
|
return builder
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_one_kvm)
|
||||||
|
.setContentTitle("One-KVM Android Host")
|
||||||
|
.setContentText(state)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"One-KVM Host",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
notificationManager().createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun createNotificationBuilder(): Notification.Builder {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
Notification.Builder(this, CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
Notification.Builder(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createContentIntent(intent: Intent): PendingIntent {
|
||||||
|
val flags = pendingIntentFlags()
|
||||||
|
return PendingIntent.getActivity(this, 0, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentFlags(): Int {
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
flags = flags or pendingIntentImmutableFlag()
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentImmutableFlag(): Int {
|
||||||
|
return try {
|
||||||
|
PendingIntent::class.java.getField("FLAG_IMMUTABLE").getInt(null)
|
||||||
|
} catch (_: ReflectiveOperationException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationManager(): NotificationManager {
|
||||||
|
return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHostRuntime(updateNotification: Boolean = true) {
|
||||||
|
stopRootHost()
|
||||||
|
NativeBridge.stopHost()
|
||||||
|
waitForPortRelease(8080, 2_000)
|
||||||
|
LogStore.flush()
|
||||||
|
ServiceStatusStore.setStopped(this)
|
||||||
|
if (updateNotification) {
|
||||||
|
notificationManager().notify(NOTIFICATION_ID, notification("已停止"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRustHost(dataDir: File): String {
|
||||||
|
val logLevel = LogConfig.getLevel(this)
|
||||||
|
val rustLog = LogConfig.rustLogFilter(logLevel)
|
||||||
|
val appLogFile = LogStore.defaultLogFile(this)
|
||||||
|
LogStore.configure(appLogFile)
|
||||||
|
val rustLogFile = appLogFile
|
||||||
|
LogStore.append("Starting One-KVM Rust host, data_dir=${dataDir.absolutePath}, log_level=$logLevel")
|
||||||
|
val executable = extractHostBinary()
|
||||||
|
return runCatching {
|
||||||
|
val tlsInit = NativeBridge.initTlsVerifier(this)
|
||||||
|
if (tlsInit != 0) {
|
||||||
|
throw IllegalStateException("rustls platform verifier init failed with code $tlsInit")
|
||||||
|
}
|
||||||
|
stopRootHost(executable)
|
||||||
|
clearExistingOtgGadgetsIfEnabled()
|
||||||
|
startRootHost(executable, dataDir, rustLog, rustLogFile, logLevel)
|
||||||
|
LogStore.append("Rust host running as root on port 8080")
|
||||||
|
"Running as root on port 8080"
|
||||||
|
}.getOrElse { rootError ->
|
||||||
|
LogStore.append("Root host unavailable: ${rootError.message ?: rootError::class.java.simpleName}")
|
||||||
|
configureAlsaEnvironment(executable)
|
||||||
|
NativeBridge.setEnv("RUST_LOG", rustLog)
|
||||||
|
NativeBridge.setEnv("ONE_KVM_FFMPEG_LOG", ffmpegLogLevel(logLevel))
|
||||||
|
NativeBridge.setEnv("ONE_KVM_ANDROID_LOG_FILE", rustLogFile.absolutePath)
|
||||||
|
val jniResult = NativeBridge.startHost(dataDir.absolutePath, "0.0.0.0", 8080)
|
||||||
|
LogStore.append("Rust host running in app process on port 8080: $jniResult")
|
||||||
|
"Running in app process on port 8080 (${rootError.message ?: "root unavailable"}; $jniResult)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearExistingOtgGadgetsIfEnabled() {
|
||||||
|
if (!HostSettings.getClearExistingOtg(this)) return
|
||||||
|
|
||||||
|
val command = """
|
||||||
|
root=/sys/kernel/config/usb_gadget
|
||||||
|
[ -d "${'$'}root" ] || exit 0
|
||||||
|
for gadget in "${'$'}root"/*; do
|
||||||
|
[ -d "${'$'}gadget" ] || continue
|
||||||
|
[ -w "${'$'}gadget/UDC" ] && echo "" > "${'$'}gadget/UDC" 2>/dev/null || true
|
||||||
|
find "${'$'}gadget/configs" -type l -delete 2>/dev/null || true
|
||||||
|
rm -rf "${'$'}gadget" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
.waitFor()
|
||||||
|
}.onSuccess { exit ->
|
||||||
|
LogStore.append("Existing OTG gadget cleanup finished with exit code $exit")
|
||||||
|
}.onFailure { err ->
|
||||||
|
LogStore.append("Existing OTG gadget cleanup failed: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureAlsaEnvironment(executable: File) {
|
||||||
|
val binDir = executable.parentFile
|
||||||
|
?: throw IllegalStateException("host binary has no parent directory")
|
||||||
|
val alsaConfigDir = File(binDir, "alsa")
|
||||||
|
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
|
||||||
|
NativeBridge.setEnv("ALSA_CONFIG_DIR", alsaConfigDir.absolutePath)
|
||||||
|
NativeBridge.setEnv("ALSA_CONFIG_PATH", alsaConfigPath.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractHostBinary(): File {
|
||||||
|
val abi = Build.SUPPORTED_ABIS.firstOrNull { it == "arm64-v8a" || it == "armeabi-v7a" }
|
||||||
|
?: throw IllegalStateException("unsupported ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
|
||||||
|
val binDir = File(filesDir, "bin/$abi")
|
||||||
|
val target = File(binDir, "one-kvm-android-host")
|
||||||
|
copyAssetIfChanged("bin/$abi/one-kvm-android-host", target)
|
||||||
|
copyAssetIfChanged("bin/$abi/libc++_shared.so", File(binDir, "libc++_shared.so"))
|
||||||
|
copyAssetIfChanged("bin/$abi/libasound.so", File(binDir, "libasound.so"))
|
||||||
|
copyAssetIfChanged("bin/$abi/libopus.so", File(binDir, "libopus.so"))
|
||||||
|
copyAssetDirectoryIfChanged("bin/$abi/alsa", File(binDir, "alsa"))
|
||||||
|
if (!target.setExecutable(true, false)) {
|
||||||
|
throw IllegalStateException("cannot mark host binary executable")
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAssetIfChanged(assetPath: String, target: File) {
|
||||||
|
val stamp = File(target.parentFile, "${target.name}.stamp")
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||||
|
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetPath"
|
||||||
|
if (target.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
assets.open(assetPath).use { input ->
|
||||||
|
target.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
stamp.writeText(expectedStamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAssetDirectoryIfChanged(assetDir: String, targetDir: File) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val packageInfo = packageManager.getPackageInfo(packageName, 0)
|
||||||
|
val stamp = File(targetDir, ".stamp")
|
||||||
|
val expectedStamp = "${packageInfo.lastUpdateTime}:$assetDir"
|
||||||
|
if (targetDir.exists() && stamp.exists() && stamp.readText() == expectedStamp) return
|
||||||
|
if (targetDir.exists()) targetDir.deleteRecursively()
|
||||||
|
copyAssetDirectory(assetDir, targetDir)
|
||||||
|
stamp.writeText(expectedStamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAssetDirectory(assetDir: String, targetDir: File) {
|
||||||
|
targetDir.mkdirs()
|
||||||
|
val children = assets.list(assetDir)?.filter { it.isNotEmpty() }.orEmpty()
|
||||||
|
for (child in children) {
|
||||||
|
val childAsset = "$assetDir/$child"
|
||||||
|
val childTarget = File(targetDir, child)
|
||||||
|
val grandChildren = assets.list(childAsset)?.filter { it.isNotEmpty() }.orEmpty()
|
||||||
|
if (grandChildren.isEmpty()) {
|
||||||
|
copyAssetIfChanged(childAsset, childTarget)
|
||||||
|
} else {
|
||||||
|
copyAssetDirectory(childAsset, childTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRootHost(
|
||||||
|
executable: File,
|
||||||
|
dataDir: File,
|
||||||
|
rustLog: String,
|
||||||
|
rustLogFile: File,
|
||||||
|
logLevel: String,
|
||||||
|
) {
|
||||||
|
stopRootHost(executable)
|
||||||
|
waitForPortRelease(8080, 2_000)
|
||||||
|
val libDir = executable.parentFile?.absolutePath
|
||||||
|
?: throw IllegalStateException("host binary has no parent directory")
|
||||||
|
val alsaConfigDir = File(executable.parentFile, "alsa")
|
||||||
|
val alsaConfigPath = File(alsaConfigDir, "alsa.conf")
|
||||||
|
val command =
|
||||||
|
"export LD_LIBRARY_PATH=${shellQuote(libDir)}:\${LD_LIBRARY_PATH:-}; " +
|
||||||
|
"export ALSA_CONFIG_DIR=${shellQuote(alsaConfigDir.absolutePath)}; " +
|
||||||
|
"export ALSA_CONFIG_PATH=${shellQuote(alsaConfigPath.absolutePath)}; " +
|
||||||
|
"export RUST_LOG=${shellQuote(rustLog)}; " +
|
||||||
|
"export ONE_KVM_FFMPEG_LOG=${shellQuote(ffmpegLogLevel(logLevel))}; " +
|
||||||
|
"export ONE_KVM_ANDROID_LOG_FILE=${shellQuote(rustLogFile.absolutePath)}; " +
|
||||||
|
"${shellQuote(executable.absolutePath)} ${shellQuote(dataDir.absolutePath)} 0.0.0.0 8080"
|
||||||
|
val process = ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
rootProcess = process
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
val readError = runCatching {
|
||||||
|
BufferedReader(InputStreamReader(process.inputStream)).useLines { lines ->
|
||||||
|
lines.forEach {
|
||||||
|
android.util.Log.i("OneKvmService", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.exceptionOrNull()
|
||||||
|
if (readError != null && readError !is InterruptedIOException) {
|
||||||
|
android.util.Log.w("OneKvmService", "Root host log reader stopped", readError)
|
||||||
|
LogStore.append("Root host log reader stopped: ${readError.message ?: readError::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
val exit = runCatching { process.waitFor() }.getOrNull()
|
||||||
|
if (rootProcess === process && exit != null) {
|
||||||
|
rootProcess = null
|
||||||
|
ServiceStatusStore.setError(this, "Root host exited with code $exit")
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
|
||||||
|
Thread.sleep(500)
|
||||||
|
val exit = runCatching { process.exitValue() }.getOrNull()
|
||||||
|
if (exit != null) {
|
||||||
|
rootProcess = null
|
||||||
|
throw IllegalStateException("root host exited immediately: $exit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(id: Int, notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val invoked = runCatching {
|
||||||
|
val method = Service::class.java.getMethod(
|
||||||
|
"startForeground",
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Notification::class.java,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
method.invoke(this, id, notification, foregroundServiceTypeConnectedDevice())
|
||||||
|
}.isSuccess
|
||||||
|
if (invoked) return
|
||||||
|
}
|
||||||
|
super.startForeground(id, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun foregroundServiceTypeConnectedDevice(): Int {
|
||||||
|
return try {
|
||||||
|
Service::class.java.getField("FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE").getInt(null)
|
||||||
|
} catch (_: ReflectiveOperationException) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRootHost(executable: File? = null) {
|
||||||
|
rootProcess?.destroy()
|
||||||
|
rootProcess = null
|
||||||
|
stopRootHostProcess(executable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRootHostProcess(executable: File? = null) {
|
||||||
|
val command = buildString {
|
||||||
|
append("pkill -TERM -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
|
||||||
|
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -TERM \"${'$'}pid\" 2>/dev/null || true; done; ")
|
||||||
|
append("sleep 0.2; ")
|
||||||
|
append("pkill -KILL -f '[o]ne-kvm-android-host' 2>/dev/null || true; ")
|
||||||
|
append("for pid in $(pidof one-kvm-android-host 2>/dev/null); do kill -KILL \"${'$'}pid\" 2>/dev/null || true; done; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
ProcessBuilder("/system/xbin/su", "0", "sh", "-c", command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
.waitFor()
|
||||||
|
}.onFailure { err ->
|
||||||
|
LogStore.append("Failed to stop stale root host: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForPortRelease(port: Int, timeoutMs: Long) {
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val inUse = isPortOpen(port, 100)
|
||||||
|
if (!inUse) return
|
||||||
|
Thread.sleep(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPortOpen(port: Int, timeoutMs: Int): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
java.net.Socket().use { socket ->
|
||||||
|
socket.connect(java.net.InetSocketAddress("127.0.0.1", port), timeoutMs)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shellQuote(value: String): String {
|
||||||
|
return "'" + value.replace("'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ffmpegLogLevel(level: String): String {
|
||||||
|
return when (level) {
|
||||||
|
"trace" -> "trace"
|
||||||
|
"debug" -> "debug"
|
||||||
|
"info" -> "info"
|
||||||
|
"warn" -> "warning"
|
||||||
|
else -> "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "one_kvm_host"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
const val ACTION_START = "cn.one_kvm.androidhost.START"
|
||||||
|
const val ACTION_STOP = "cn.one_kvm.androidhost.STOP"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, OneKvmService::class.java).setAction(ACTION_START)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(Intent(context, OneKvmService::class.java).setAction(ACTION_STOP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package cn.one_kvm.androidhost
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object ServiceStatusStore {
|
||||||
|
private const val PREFS = "one_kvm_android_status"
|
||||||
|
private const val KEY_STATE = "state"
|
||||||
|
private const val KEY_MESSAGE = "message"
|
||||||
|
private const val KEY_UPDATED_AT = "updated_at"
|
||||||
|
|
||||||
|
const val STATE_STOPPED = "stopped"
|
||||||
|
const val STATE_STARTING = "starting"
|
||||||
|
const val STATE_RUNNING = "running"
|
||||||
|
const val STATE_STOPPING = "stopping"
|
||||||
|
const val STATE_ERROR = "error"
|
||||||
|
|
||||||
|
data class Snapshot(
|
||||||
|
val state: String,
|
||||||
|
val message: String,
|
||||||
|
val updatedAt: Long,
|
||||||
|
) {
|
||||||
|
fun labelText(): String {
|
||||||
|
return when (state) {
|
||||||
|
STATE_STARTING -> "启动中"
|
||||||
|
STATE_RUNNING -> "运行中"
|
||||||
|
STATE_STOPPING -> "停止中"
|
||||||
|
STATE_ERROR -> "错误"
|
||||||
|
else -> "已停止"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayText(): String {
|
||||||
|
val label = labelText()
|
||||||
|
return if (message.isBlank()) label else "$label:$message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStarting(context: Context, message: String = "正在启动服务") {
|
||||||
|
write(context, STATE_STARTING, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRunning(context: Context, message: String) {
|
||||||
|
write(context, STATE_RUNNING, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStopping(context: Context, message: String = "正在停止服务") {
|
||||||
|
write(context, STATE_STOPPING, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStopped(context: Context, message: String = "服务已停止") {
|
||||||
|
write(context, STATE_STOPPED, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setError(context: Context, message: String) {
|
||||||
|
write(context, STATE_ERROR, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(context: Context): Snapshot {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return Snapshot(
|
||||||
|
state = prefs.getString(KEY_STATE, STATE_STOPPED) ?: STATE_STOPPED,
|
||||||
|
message = prefs.getString(KEY_MESSAGE, "") ?: "",
|
||||||
|
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(context: Context, state: String, message: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_STATE, state)
|
||||||
|
.putString(KEY_MESSAGE, message)
|
||||||
|
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
38
android/app/src/main/res/drawable/ic_launcher_one_kvm.xml
Normal file
38
android/app/src/main/res/drawable/ic_launcher_one_kvm.xml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1D7BF2"
|
||||||
|
android:pathData="M24,0h60a24,24 0,0 1,24 24v60a24,24 0,0 1,-24 24H24a24,24 0,0 1,-24 -24V24a24,24 0,0 1,24 -24z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#AED8E8"
|
||||||
|
android:pathData="M29,25h50a3,3 0,0 1,3 3v31a3,3 0,0 1,-3 3H29a3,3 0,0 1,-3 -3V28a3,3 0,0 1,3 -3z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#3F3F3D"
|
||||||
|
android:pathData="M31,30h46v27H31z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#E7F1F4"
|
||||||
|
android:pathData="M31,26h10a1.4,1.4 0,0 1,0 2.8H31a1.4,1.4 0,0 1,0 -2.8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#8BBFD1"
|
||||||
|
android:pathData="M49,62h10l1.5,8h-13z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#9FCFE0"
|
||||||
|
android:pathData="M40,70a14,4.5 0,1 0,28 0a14,4.5 0,1 0,-28 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#E8F5F8"
|
||||||
|
android:pathData="M45,70a7,1.8 0,1 0,14 0a7,1.8 0,1 0,-14 0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#BFE6F1"
|
||||||
|
android:pathData="M32,76h38l5,8H27z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#76ADC2"
|
||||||
|
android:pathData="M28,84h47v2H28z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillAlpha="0.82"
|
||||||
|
android:pathData="M37,79h6v2h-6zM46,79h5v2h-5zM54,79h5v2h-5zM62,79h6v2h-6zM34,82h7v2h-7zM44,82h6v2h-6zM53,82h11v2H53zM67,82h4v2h-4z" />
|
||||||
|
</vector>
|
||||||
13
android/app/src/main/res/drawable/ic_stat_one_kvm.xml
Normal file
13
android/app/src/main/res/drawable/ic_stat_one_kvm.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4,5h16v10H4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M9,17h6v2h3v2H6v-2h3z" />
|
||||||
|
</vector>
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">One-KVM Android Host</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/styles.xml
Normal file
7
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
<item name="android:colorAccent">#2563EB</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
3
android/build.gradle.kts
Normal file
3
android/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "9.0.0" apply false
|
||||||
|
}
|
||||||
3
android/gradle.properties
Normal file
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
android/gradlew
vendored
Normal file
251
android/gradlew
vendored
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
21
android/native/Cargo.toml
Normal file
21
android/native/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "one-kvm-android-bootstrap"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "one_kvm_android_bootstrap"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "one-kvm-android-host"
|
||||||
|
path = "src/bin/one-kvm-android-host.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
jni = "0.22.4"
|
||||||
|
one-kvm = { path = "../..", default-features = false, features = ["android", "android-mediacodec"] }
|
||||||
|
rustls-platform-verifier = "0.7"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
android-mediacodec = ["one-kvm/android-mediacodec"]
|
||||||
24
android/native/src/bin/one-kvm-android-host.rs
Normal file
24
android/native/src/bin/one-kvm-android-host.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let data_dir = args
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| "/data/local/tmp/one-kvm".to_string());
|
||||||
|
let bind_address = args.next().unwrap_or_else(|| "0.0.0.0".to_string());
|
||||||
|
let port = args
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.parse::<u16>().ok())
|
||||||
|
.unwrap_or(8080);
|
||||||
|
|
||||||
|
one_kvm::runtime::android::init_rustls_provider();
|
||||||
|
|
||||||
|
if let Err(err) = android::run_foreground(AndroidRuntimeConfig {
|
||||||
|
data_dir,
|
||||||
|
bind_address,
|
||||||
|
port,
|
||||||
|
}) {
|
||||||
|
eprintln!("one-kvm android host failed: {err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
android/native/src/lib.rs
Normal file
182
android/native/src/lib.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use jni::errors::{ErrorPolicy, ThrowRuntimeExAndDefault};
|
||||||
|
use jni::objects::{JClass, JObject, JString};
|
||||||
|
use jni::sys::{jint, jstring};
|
||||||
|
use jni::{Env, EnvOutcome, EnvUnowned};
|
||||||
|
use one_kvm::runtime::android::{self, AndroidRuntimeConfig};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct BridgeError(String);
|
||||||
|
|
||||||
|
impl From<jni::errors::Error> for BridgeError {
|
||||||
|
fn from(err: jni::errors::Error) -> Self {
|
||||||
|
Self(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for BridgeError {
|
||||||
|
fn from(err: String) -> Self {
|
||||||
|
Self(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for BridgeError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct StatusPolicy;
|
||||||
|
|
||||||
|
impl ErrorPolicy<jint, BridgeError> for StatusPolicy {
|
||||||
|
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
|
||||||
|
|
||||||
|
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
|
||||||
|
_env: &mut Env<'unowned_env_local>,
|
||||||
|
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
|
||||||
|
_err: BridgeError,
|
||||||
|
) -> jni::errors::Result<jint> {
|
||||||
|
Ok(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
|
||||||
|
_env: &mut Env<'unowned_env_local>,
|
||||||
|
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
|
||||||
|
_payload: Box<dyn std::any::Any + Send + 'static>,
|
||||||
|
) -> jni::errors::Result<jint> {
|
||||||
|
Ok(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct StringResultPolicy;
|
||||||
|
|
||||||
|
impl ErrorPolicy<String, BridgeError> for StringResultPolicy {
|
||||||
|
type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
|
||||||
|
|
||||||
|
fn on_error<'unowned_env_local: 'native_method, 'native_method>(
|
||||||
|
_env: &mut Env<'unowned_env_local>,
|
||||||
|
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
|
||||||
|
err: BridgeError,
|
||||||
|
) -> jni::errors::Result<String> {
|
||||||
|
Ok(format!("start failed: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
|
||||||
|
_env: &mut Env<'unowned_env_local>,
|
||||||
|
_cap: &mut Self::Captures<'unowned_env_local, 'native_method>,
|
||||||
|
_payload: Box<dyn std::any::Any + Send + 'static>,
|
||||||
|
) -> jni::errors::Result<String> {
|
||||||
|
Ok("start failed: panic in native bridge".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_setEnv<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
name: JString<'local>,
|
||||||
|
value: JString<'local>,
|
||||||
|
) -> jint {
|
||||||
|
let outcome: EnvOutcome<'local, jint, BridgeError> = env.with_env_no_catch(|env| {
|
||||||
|
let name = name
|
||||||
|
.try_to_string(env)
|
||||||
|
.map_err(|err| BridgeError(format!("invalid env name: {err}")))?;
|
||||||
|
let value = value
|
||||||
|
.try_to_string(env)
|
||||||
|
.map_err(|err| BridgeError(format!("invalid env value: {err}")))?;
|
||||||
|
if name.contains('\0') || value.contains('\0') {
|
||||||
|
return Err(BridgeError("env contains NUL".to_string()));
|
||||||
|
}
|
||||||
|
std::env::set_var(name, value);
|
||||||
|
Ok(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
outcome.resolve_with::<StatusPolicy, _>(|| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_initTlsVerifier<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
context: JObject<'local>,
|
||||||
|
) -> jint {
|
||||||
|
let outcome: EnvOutcome<'local, jint, BridgeError> =
|
||||||
|
env.with_env_no_catch(|env| init_tls_verifier(env, context));
|
||||||
|
|
||||||
|
outcome.resolve_with::<StatusPolicy, _>(|| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn init_tls_verifier(env: &mut Env<'_>, context: JObject<'_>) -> Result<jint, BridgeError> {
|
||||||
|
rustls_platform_verifier::android::init_with_env(env, context)
|
||||||
|
.map_err(|err| BridgeError(format!("failed to initialize rustls platform verifier: {err}")))?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn init_tls_verifier(_env: &mut Env<'_>, _context: JObject<'_>) -> Result<jint, BridgeError> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_startHost<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
data_dir: JString<'local>,
|
||||||
|
bind_address: JString<'local>,
|
||||||
|
port: i32,
|
||||||
|
) -> jstring {
|
||||||
|
let outcome: EnvOutcome<'local, String, BridgeError> = env.with_env_no_catch(|env| {
|
||||||
|
let data_dir = data_dir
|
||||||
|
.try_to_string(env)
|
||||||
|
.map_err(|err| BridgeError(format!("invalid data dir: {err}")))?;
|
||||||
|
let bind_address = bind_address
|
||||||
|
.try_to_string(env)
|
||||||
|
.map_err(|err| BridgeError(format!("invalid bind address: {err}")))?;
|
||||||
|
let port = u16::try_from(port).map_err(|_| BridgeError("invalid port".to_string()))?;
|
||||||
|
|
||||||
|
android::start(AndroidRuntimeConfig {
|
||||||
|
data_dir,
|
||||||
|
bind_address,
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
.map_err(BridgeError)
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = outcome.resolve_with::<StringResultPolicy, _>(|| ());
|
||||||
|
|
||||||
|
env.with_env_no_catch(|env| env.new_string(result))
|
||||||
|
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
|
||||||
|
.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_stopHost<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
env.with_env_no_catch(|env| env.new_string(android::stop()))
|
||||||
|
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
|
||||||
|
.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_hostStatus<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
env.with_env_no_catch(|env| env.new_string(android::status()))
|
||||||
|
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
|
||||||
|
.into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_cn_one_1kvm_androidhost_NativeBridge_kernelVersion<'local>(
|
||||||
|
mut env: EnvUnowned<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
env.with_env_no_catch(|env| env.new_string(env!("CARGO_PKG_VERSION")))
|
||||||
|
.resolve_with::<ThrowRuntimeExAndDefault, _>(|| ())
|
||||||
|
.into_raw()
|
||||||
|
}
|
||||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "OneKvmAndroidHost"
|
||||||
|
include(":app")
|
||||||
88
build/build-android.sh
Normal file
88
build/build-android.sh
Normal file
@@ -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
|
||||||
300
build/cross/Dockerfile.android
Normal file
300
build/cross/Dockerfile.android
Normal file
@@ -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 <<EOF' \
|
||||||
|
'Usage:' \
|
||||||
|
' docker run --rm -v "$PWD:/workspace" one-kvm-android-build:cn [arm64|armv7|all|help]' \
|
||||||
|
'' \
|
||||||
|
'Commands:' \
|
||||||
|
' all Build arm64 and armv7 APKs. Default.' \
|
||||||
|
' arm64 Build only arm64 APK.' \
|
||||||
|
' armv7 Build only ARMv7 APK.' \
|
||||||
|
' help Show this help.' \
|
||||||
|
'' \
|
||||||
|
'APK output:' \
|
||||||
|
' target/android/' \
|
||||||
|
'EOF' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
|
'fail() {' \
|
||||||
|
' echo "Error: $*" >&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"]
|
||||||
@@ -95,6 +95,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
|
|||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr/aarch64-linux-gnu \
|
-DCMAKE_INSTALL_PREFIX=/usr/aarch64-linux-gnu \
|
||||||
|
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||||
-DCMAKE_SYSTEM_NAME=Linux \
|
-DCMAKE_SYSTEM_NAME=Linux \
|
||||||
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
|
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
|
||||||
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
|
-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_SYSTEM_PROCESSOR=aarch64 \
|
||||||
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
|
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
|
||||||
-DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \
|
-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 -j$(nproc) \
|
||||||
&& make install \
|
&& make install \
|
||||||
&& rm -rf /tmp/libyuv
|
&& rm -rf /tmp/libyuv
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
|
|||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf \
|
-DCMAKE_INSTALL_PREFIX=/usr/arm-linux-gnueabihf \
|
||||||
|
-DCMAKE_INSTALL_LIBDIR=lib \
|
||||||
-DCMAKE_SYSTEM_NAME=Linux \
|
-DCMAKE_SYSTEM_NAME=Linux \
|
||||||
-DCMAKE_SYSTEM_PROCESSOR=arm \
|
-DCMAKE_SYSTEM_PROCESSOR=arm \
|
||||||
-DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
|
-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_SYSTEM_PROCESSOR=arm \
|
||||||
-DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
|
-DCMAKE_C_COMPILER=arm-linux-gnueabihf-gcc \
|
||||||
-DCMAKE_CXX_COMPILER=arm-linux-gnueabihf-g++ \
|
-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 -j$(nproc) \
|
||||||
&& make install \
|
&& make install \
|
||||||
&& rm -rf /tmp/libyuv
|
&& rm -rf /tmp/libyuv
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ RUN git clone --depth 1 https://github.com/libjpeg-turbo/libjpeg-turbo /tmp/libj
|
|||||||
&& cd /tmp/libjpeg-turbo \
|
&& cd /tmp/libjpeg-turbo \
|
||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
&& 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 \
|
-DENABLE_SHARED=OFF -DENABLE_STATIC=ON \
|
||||||
&& make -j$(nproc) \
|
&& make -j$(nproc) \
|
||||||
&& make install \
|
&& make install \
|
||||||
@@ -102,6 +104,11 @@ RUN git clone --depth 1 https://github.com/lemenkov/libyuv /tmp/libyuv \
|
|||||||
&& cd /tmp/libyuv \
|
&& cd /tmp/libyuv \
|
||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
&& 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 -j$(nproc) \
|
||||||
&& make install \
|
&& make install \
|
||||||
&& rm -rf /tmp/libyuv
|
&& rm -rf /tmp/libyuv
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ ignored = ["serde"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
bytes = ["dep:bytes"]
|
||||||
rkmpp = []
|
rkmpp = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
bytes = { version = "1", optional = true }
|
||||||
serde_derive = "1.0"
|
serde_derive = "1.0"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
cc = "1.0"
|
cc = "1.0"
|
||||||
bindgen = "0.59"
|
bindgen = "0.70.1"
|
||||||
|
|||||||
@@ -21,11 +21,16 @@ fn build_common(builder: &mut Build) {
|
|||||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||||
let common_dir = manifest_dir.join("cpp").join("common");
|
let common_dir = manifest_dir.join("cpp").join("common");
|
||||||
|
|
||||||
bindgen::builder()
|
let mut bindings = bindgen::builder()
|
||||||
.header(common_dir.join("common.h").to_string_lossy().to_string())
|
.header(common_dir.join("common.h").to_string_lossy().to_string())
|
||||||
.header(common_dir.join("callback.h").to_string_lossy().to_string())
|
.header(common_dir.join("callback.h").to_string_lossy().to_string())
|
||||||
.rustified_enum("*")
|
.rustified_enum(".*")
|
||||||
.parse_callbacks(Box::new(CommonCallbacks))
|
.parse_callbacks(Box::new(CommonCallbacks));
|
||||||
|
if target_os == "android" {
|
||||||
|
print_android_bindgen_env();
|
||||||
|
bindings = bindings.clang_args(android_clang_args());
|
||||||
|
}
|
||||||
|
bindings
|
||||||
.generate()
|
.generate()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("common_ffi.rs"))
|
.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
|
// Unsupported platforms
|
||||||
if target_os != "windows" && target_os != "linux" {
|
if target_os != "windows" && target_os != "linux" && target_os != "android" {
|
||||||
panic!(
|
panic!(
|
||||||
"Unsupported OS: {}. Only Windows and Linux are supported.",
|
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.",
|
||||||
target_os
|
target_os
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,9 +76,9 @@ fn build_common(builder: &mut Build) {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CommonCallbacks;
|
struct CommonCallbacks;
|
||||||
impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
|
impl bindgen::callbacks::ParseCallbacks for CommonCallbacks {
|
||||||
fn add_derives(&self, name: &str) -> Vec<String> {
|
fn add_derives(&self, info: &bindgen::callbacks::DeriveInfo<'_>) -> Vec<String> {
|
||||||
let names = vec!["DataFormat", "SurfaceFormat", "API"];
|
let names = vec!["DataFormat", "SurfaceFormat", "API"];
|
||||||
if names.contains(&name) {
|
if names.contains(&info.name) {
|
||||||
vec!["Serialize", "Deserialize"]
|
vec!["Serialize", "Deserialize"]
|
||||||
.drain(..)
|
.drain(..)
|
||||||
.map(|s| s.to_string())
|
.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<String> {
|
||||||
|
let ndk = android_ndk_home();
|
||||||
|
let target = env::var("TARGET").unwrap_or_default();
|
||||||
|
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
|
||||||
|
let sysroot = toolchain.join("sysroot");
|
||||||
|
let clang_include = toolchain
|
||||||
|
.join("lib/clang")
|
||||||
|
.join(clang_version(&toolchain))
|
||||||
|
.join("include");
|
||||||
|
let api = env::var("CARGO_NDK_PLATFORM")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.unwrap_or(21);
|
||||||
|
let clang_target = android_clang_target(&target);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
format!("--target={clang_target}"),
|
||||||
|
format!("--sysroot={}", sysroot.display()),
|
||||||
|
format!("-D__ANDROID_API__={api}"),
|
||||||
|
format!("-isystem{}", clang_include.display()),
|
||||||
|
format!("-isystem{}", sysroot.join("usr/include").display()),
|
||||||
|
format!(
|
||||||
|
"-isystem{}",
|
||||||
|
sysroot.join("usr/include").join(clang_target).display()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_clang_target(target: &str) -> &'static str {
|
||||||
|
match target {
|
||||||
|
"aarch64-linux-android" => "aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
|
||||||
|
"i686-linux-android" => "i686-linux-android",
|
||||||
|
"x86_64-linux-android" => "x86_64-linux-android",
|
||||||
|
other => panic!("unsupported Android target for hwcodec bindgen: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_ndk_home() -> PathBuf {
|
||||||
|
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
|
||||||
|
if let Ok(value) = env::var(key) {
|
||||||
|
return PathBuf::from(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
|
||||||
|
if let Ok(value) = env::var(key) {
|
||||||
|
let ndk_dir = PathBuf::from(value).join("ndk");
|
||||||
|
if let Some(newest) = newest_child_dir(&ndk_dir) {
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!(
|
||||||
|
"hwcodec Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
|
||||||
|
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut entries = std::fs::read_dir(path)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.is_dir())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
entries.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_tag() -> &'static str {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
"linux-x86_64"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"darwin-x86_64"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"windows-x86_64"
|
||||||
|
} else {
|
||||||
|
panic!("unsupported host OS for Android NDK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clang_version(toolchain: &Path) -> String {
|
||||||
|
let clang_dir = toolchain.join("lib/clang");
|
||||||
|
let mut entries = std::fs::read_dir(&clang_dir)
|
||||||
|
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
entries
|
||||||
|
.pop()
|
||||||
|
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
|
||||||
|
}
|
||||||
|
|
||||||
mod ffmpeg {
|
mod ffmpeg {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub fn build_ffmpeg(builder: &mut Build) {
|
pub fn build_ffmpeg(builder: &mut Build) {
|
||||||
ffmpeg_ffi();
|
ffmpeg_ffi();
|
||||||
|
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
|
||||||
|
link_android_ffmpeg(builder);
|
||||||
|
build_ffmpeg_ram(builder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Try VCPKG first, fallback to system FFmpeg via pkg-config
|
// Try VCPKG first, fallback to system FFmpeg via pkg-config
|
||||||
if let Some(vcpkg_installed) = vcpkg_installed_root() {
|
if let Some(vcpkg_installed) = vcpkg_installed_root() {
|
||||||
link_vcpkg(builder, vcpkg_installed);
|
link_vcpkg(builder, vcpkg_installed);
|
||||||
@@ -104,6 +220,67 @@ mod ffmpeg {
|
|||||||
build_ffmpeg_capture(builder);
|
build_ffmpeg_capture(builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn link_android_ffmpeg(builder: &mut Build) {
|
||||||
|
let root = std::env::var("ONE_KVM_ANDROID_FFMPEG_ROOT").unwrap_or_else(|_| {
|
||||||
|
panic!(
|
||||||
|
"ONE_KVM_ANDROID_FFMPEG_ROOT is required when building hwcodec for Android. \
|
||||||
|
It must point to an FFmpeg Android build with MediaCodec enabled."
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let root = PathBuf::from(root);
|
||||||
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
|
let abi = match target_arch.as_str() {
|
||||||
|
"aarch64" => "arm64-v8a",
|
||||||
|
"arm" => "armeabi-v7a",
|
||||||
|
"x86" => "x86",
|
||||||
|
"x86_64" => "x86_64",
|
||||||
|
_ => target_arch.as_str(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let abi_root = root.join(abi);
|
||||||
|
let lib_dir = if abi_root.join("lib").exists() {
|
||||||
|
abi_root.join("lib")
|
||||||
|
} else {
|
||||||
|
root.join("lib")
|
||||||
|
};
|
||||||
|
let include_dir = if abi_root.join("include").exists() {
|
||||||
|
abi_root.join("include")
|
||||||
|
} else {
|
||||||
|
root.join("include")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !include_dir.exists() || !lib_dir.exists() {
|
||||||
|
panic!(
|
||||||
|
"Invalid ONE_KVM_ANDROID_FFMPEG_ROOT: include/lib not found for ABI {} under {}",
|
||||||
|
abi,
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-search=native={}", lib_dir.display());
|
||||||
|
builder.include(&include_dir);
|
||||||
|
|
||||||
|
let use_static = std::env::var("ONE_KVM_ANDROID_FFMPEG_STATIC")
|
||||||
|
.map(|value| value != "0")
|
||||||
|
.unwrap_or(true);
|
||||||
|
for lib in ["avcodec", "avutil"] {
|
||||||
|
if use_static {
|
||||||
|
println!("cargo:rustc-link-lib=static={}", lib);
|
||||||
|
} else {
|
||||||
|
println!("cargo:rustc-link-lib={}", lib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rustc-link-lib=log");
|
||||||
|
println!("cargo:rustc-link-lib=mediandk");
|
||||||
|
println!("cargo:rustc-link-lib=android");
|
||||||
|
println!("cargo:rustc-link-lib=dl");
|
||||||
|
println!("cargo:rustc-link-lib=m");
|
||||||
|
println!("cargo:rustc-link-lib=z");
|
||||||
|
println!("cargo:rustc-link-lib=c++_shared");
|
||||||
|
println!("cargo:info=Using Android FFmpeg from {}", root.display());
|
||||||
|
}
|
||||||
|
|
||||||
fn vcpkg_installed_root() -> Option<PathBuf> {
|
fn vcpkg_installed_root() -> Option<PathBuf> {
|
||||||
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
|
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
|
||||||
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
|
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
|
||||||
@@ -335,7 +512,10 @@ mod ffmpeg {
|
|||||||
return;
|
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() {
|
fn link_os() {
|
||||||
@@ -358,9 +538,11 @@ mod ffmpeg {
|
|||||||
}
|
}
|
||||||
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
|
// ARM (aarch64, arm): no X11 needed, uses RKMPP/V4L2
|
||||||
v
|
v
|
||||||
|
} else if target_os == "android" {
|
||||||
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
panic!(
|
panic!(
|
||||||
"Unsupported OS: {}. Only Windows and Linux are supported.",
|
"Unsupported OS: {}. Only Windows, Linux, and Android are supported.",
|
||||||
target_os
|
target_os
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -376,9 +558,14 @@ mod ffmpeg {
|
|||||||
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
|
let ffi_header_path = ffmpeg_ram_dir.join("ffmpeg_ffi.h");
|
||||||
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
|
println!("cargo:rerun-if-changed={}", ffi_header_path.display());
|
||||||
let ffi_header = ffi_header_path.to_string_lossy().to_string();
|
let ffi_header = ffi_header_path.to_string_lossy().to_string();
|
||||||
bindgen::builder()
|
let mut bindings = bindgen::builder()
|
||||||
.header(ffi_header)
|
.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()
|
.generate()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ffi.rs"))
|
.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")
|
.join("ffmpeg_ram_ffi.h")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
bindgen::builder()
|
let mut bindings = bindgen::builder()
|
||||||
.header(ffi_header)
|
.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()
|
.generate()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_ram_ffi.rs"))
|
.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.
|
// RKMPP decode only exists on ARM builds where FFmpeg is compiled with RKMPP support.
|
||||||
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
|
// Avoid compiling this file on x86/x64 where `AV_HWDEVICE_TYPE_RKMPP` doesn't exist.
|
||||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
let 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();
|
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
|
||||||
if enable_rkmpp {
|
if enable_rkmpp {
|
||||||
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
|
builder.file(ffmpeg_ram_dir.join("ffmpeg_ram_decode.cpp"));
|
||||||
@@ -431,7 +625,7 @@ mod ffmpeg {
|
|||||||
.to_string();
|
.to_string();
|
||||||
bindgen::builder()
|
bindgen::builder()
|
||||||
.header(capture_header)
|
.header(capture_header)
|
||||||
.rustified_enum("*")
|
.rustified_enum(".*")
|
||||||
.generate()
|
.generate()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_to_file(
|
.write_to_file(
|
||||||
@@ -454,14 +648,16 @@ mod ffmpeg {
|
|||||||
.to_string();
|
.to_string();
|
||||||
bindgen::builder()
|
bindgen::builder()
|
||||||
.header(ffi_header)
|
.header(ffi_header)
|
||||||
.rustified_enum("*")
|
.rustified_enum(".*")
|
||||||
.generate()
|
.generate()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_hw_ffi.rs"))
|
.write_to_file(Path::new(&env::var_os("OUT_DIR").unwrap()).join("ffmpeg_hw_ffi.rs"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
let 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();
|
|| std::env::var_os("CARGO_FEATURE_RKMPP").is_some();
|
||||||
if enable_rkmpp {
|
if enable_rkmpp {
|
||||||
// Include RGA headers for NV16->NV12 conversion (RGA im2d API)
|
// Include RGA headers for NV16->NV12 conversion (RGA im2d API)
|
||||||
|
|||||||
@@ -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_primaries = AVCOL_PRI_SMPTE170M;
|
||||||
c->color_trc = AVCOL_TRC_SMPTE170M;
|
c->color_trc = AVCOL_TRC_SMPTE170M;
|
||||||
|
|
||||||
// WebRTC SDP advertises constrained baseline. Keep hardware and software
|
// WebRTC SDP advertises constrained baseline. Keep most hardware and software
|
||||||
// encoders on the same browser-friendly H264 profile.
|
// 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) {
|
if (name.find("h264") != std::string::npos) {
|
||||||
c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE;
|
c->profile = AV_PROFILE_H264_CONSTRAINED_BASELINE;
|
||||||
} else if (name.find("hevc") != std::string::npos) {
|
} else if (name.find("hevc") != std::string::npos) {
|
||||||
@@ -305,23 +310,9 @@ bool set_quality(void *priv_data, const std::string &name, int quality) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (name.find("mediacodec") != std::string::npos) {
|
// Do not force MediaCodec level here. Some Android TV vendor encoders,
|
||||||
if (name.find("h264") != std::string::npos) {
|
// including older Amlogic OMX implementations, reject explicit level values
|
||||||
if ((ret = av_opt_set(priv_data, "level", "5.1", 0)) < 0) {
|
// even when they support the requested resolution and bitrate.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// libx264 software encoder presets
|
// libx264 software encoder presets
|
||||||
if (is_software_h264(name)) {
|
if (is_software_h264(name)) {
|
||||||
const char* preset = nullptr;
|
const char* preset = nullptr;
|
||||||
@@ -457,6 +448,13 @@ bool set_others(void *priv_data, const std::string &name) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (name.find("mediacodec") != std::string::npos) {
|
||||||
|
if ((ret = av_opt_set_int(priv_data, "ndk_codec", 1, 0)) < 0) {
|
||||||
|
LOG_ERROR(std::string("mediacodec set ndk_codec failed, ret = ") +
|
||||||
|
av_err2str(ret));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
// NOTE: Removed idr_interval = INT_MAX for VAAPI.
|
// NOTE: Removed idr_interval = INT_MAX for VAAPI.
|
||||||
// This was disabling automatic keyframe generation.
|
// This was disabling automatic keyframe generation.
|
||||||
// The encoder should respect c->gop_size for keyframe interval.
|
// The encoder should respect c->gop_size for keyframe interval.
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ public:
|
|||||||
av_buffer_unref(&frames_ref);
|
av_buffer_unref(&frames_ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name_.find("mediacodec") != std::string::npos && c_->priv_data) {
|
||||||
|
if ((ret = av_opt_set_int(c_->priv_data, "ndk_codec", 1, 0)) < 0) {
|
||||||
|
LOG_WARN(std::string("mediacodec decoder ndk_codec option failed, ret = ") +
|
||||||
|
av_err2str(ret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
|
if ((ret = avcodec_open2(c_, codec, NULL)) < 0) {
|
||||||
set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret));
|
set_last_error(std::string("avcodec_open2 failed, ret = ") + av_err2str(ret));
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ _exit:
|
|||||||
namespace {
|
namespace {
|
||||||
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
|
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
|
||||||
int key, const void *obj);
|
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 {
|
class FFmpegRamEncoder {
|
||||||
public:
|
public:
|
||||||
@@ -134,6 +137,7 @@ public:
|
|||||||
int thread_count_ = 1;
|
int thread_count_ = 1;
|
||||||
int gpu_ = 0;
|
int gpu_ = 0;
|
||||||
RamEncodeCallback callback_ = NULL;
|
RamEncodeCallback callback_ = NULL;
|
||||||
|
RamEncodePacketCallback packet_callback_ = NULL;
|
||||||
int offset_[AV_NUM_DATA_POINTERS] = {0};
|
int offset_[AV_NUM_DATA_POINTERS] = {0};
|
||||||
bool force_keyframe_ = false; // Force next frame to be a keyframe
|
bool force_keyframe_ = false; // Force next frame to be a keyframe
|
||||||
|
|
||||||
@@ -141,6 +145,7 @@ public:
|
|||||||
AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE;
|
AVPixelFormat hw_pixfmt_ = AV_PIX_FMT_NONE;
|
||||||
AVBufferRef *hw_device_ctx_ = NULL;
|
AVBufferRef *hw_device_ctx_ = NULL;
|
||||||
AVFrame *hw_frame_ = NULL;
|
AVFrame *hw_frame_ = NULL;
|
||||||
|
AVFrame *borrowed_frame_ = NULL;
|
||||||
|
|
||||||
FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height,
|
FFmpegRamEncoder(const char *name, const char *mc_name, int width, int height,
|
||||||
int pixfmt, int align, int fps, int gop, int rc, int quality,
|
int pixfmt, int align, int fps, int gop, int rc, int quality,
|
||||||
@@ -247,6 +252,11 @@ public:
|
|||||||
LOG_ERROR(std::string("Could not allocate video packet"));
|
LOG_ERROR(std::string("Could not allocate video packet"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
borrowed_frame_ = av_frame_alloc();
|
||||||
|
if (!borrowed_frame_) {
|
||||||
|
LOG_ERROR(std::string("Could not allocate borrowed video frame"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/* resolution must be a multiple of two */
|
/* resolution must be a multiple of two */
|
||||||
c_->width = width_;
|
c_->width = width_;
|
||||||
@@ -297,11 +307,19 @@ public:
|
|||||||
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) {
|
int encode(const uint8_t *data, int length, const void *obj, uint64_t ms) {
|
||||||
int ret;
|
int ret;
|
||||||
|
|
||||||
|
if (can_borrow_input(length)) {
|
||||||
|
AVFrame *borrowed = wrap_borrowed_frame(data, length);
|
||||||
|
if (!borrowed) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return do_encode(borrowed, obj, ms);
|
||||||
|
}
|
||||||
|
|
||||||
if ((ret = av_frame_make_writable(frame_)) != 0) {
|
if ((ret = av_frame_make_writable(frame_)) != 0) {
|
||||||
LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret));
|
LOG_ERROR(std::string("av_frame_make_writable failed, ret = ") + av_err2str(ret));
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
if ((ret = fill_frame(frame_, (uint8_t *)data, length, offset_)) != 0)
|
if ((ret = fill_frame(frame_, data, length, offset_)) != 0)
|
||||||
return ret;
|
return ret;
|
||||||
AVFrame *tmp_frame;
|
AVFrame *tmp_frame;
|
||||||
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
|
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
|
||||||
@@ -317,6 +335,14 @@ public:
|
|||||||
return do_encode(tmp_frame, obj, ms);
|
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() {
|
void free_encoder() {
|
||||||
if (pkt_)
|
if (pkt_)
|
||||||
av_packet_free(&pkt_);
|
av_packet_free(&pkt_);
|
||||||
@@ -324,6 +350,8 @@ public:
|
|||||||
av_frame_free(&frame_);
|
av_frame_free(&frame_);
|
||||||
if (hw_frame_)
|
if (hw_frame_)
|
||||||
av_frame_free(&hw_frame_);
|
av_frame_free(&hw_frame_);
|
||||||
|
if (borrowed_frame_)
|
||||||
|
av_frame_free(&borrowed_frame_);
|
||||||
if (hw_device_ctx_)
|
if (hw_device_ctx_)
|
||||||
av_buffer_unref(&hw_device_ctx_);
|
av_buffer_unref(&hw_device_ctx_);
|
||||||
if (c_)
|
if (c_)
|
||||||
@@ -376,101 +404,203 @@ private:
|
|||||||
frame->pict_type = AV_PICTURE_TYPE_NONE;
|
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));
|
LOG_ERROR(std::string("avcodec_send_frame failed, ret = ") + av_err2str(ret));
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto start = util::now();
|
ret = receive_available_packets(obj, encoded);
|
||||||
while (ret >= 0 && util::elapsed_ms(start) < DECODE_TIMEOUT_MS) {
|
if (ret < 0) {
|
||||||
if ((ret = avcodec_receive_packet(c_, pkt_)) < 0) {
|
return ret;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
_exit:
|
|
||||||
av_packet_unref(pkt_);
|
|
||||||
// If no packet is produced for this input frame, treat it as EAGAIN.
|
// 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).
|
// This is not a fatal error: encoders may buffer internally (e.g., startup delay).
|
||||||
return encoded ? 0 : AVERROR(EAGAIN);
|
return encoded ? 0 : AVERROR(EAGAIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
int fill_frame(AVFrame *frame, uint8_t *data, int data_length,
|
int receive_available_packets(const void *obj, bool &encoded) {
|
||||||
const int *const offset) {
|
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<size_t>(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) {
|
switch (frame->format) {
|
||||||
case AV_PIX_FMT_NV12:
|
case AV_PIX_FMT_NV12:
|
||||||
case AV_PIX_FMT_NV21:
|
case AV_PIX_FMT_NV21:
|
||||||
if (data_length <
|
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:") +
|
LOG_ERROR(std::string("fill_frame: NV12/NV21 data length error. data_length:") +
|
||||||
std::to_string(data_length) +
|
std::to_string(data_length) +
|
||||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
", width:" + std::to_string(width_) +
|
||||||
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
", 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;
|
return -1;
|
||||||
}
|
}
|
||||||
frame->data[0] = data;
|
|
||||||
frame->data[1] = data + offset[0];
|
|
||||||
break;
|
break;
|
||||||
case AV_PIX_FMT_NV16:
|
case AV_PIX_FMT_NV16:
|
||||||
case AV_PIX_FMT_NV24:
|
|
||||||
if (data_length <
|
if (data_length <
|
||||||
frame->height * (frame->linesize[0] + frame->linesize[1])) {
|
frame->height * src_y_stride + frame->height * src_uv_stride) {
|
||||||
LOG_ERROR(std::string("fill_frame: NV16/NV24 data length error. data_length:") +
|
LOG_ERROR(std::string("fill_frame: NV16 data length error. data_length:") +
|
||||||
std::to_string(data_length) +
|
std::to_string(data_length) +
|
||||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
", width:" + std::to_string(width_) +
|
||||||
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
", 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;
|
return -1;
|
||||||
}
|
}
|
||||||
frame->data[0] = data;
|
|
||||||
frame->data[1] = data + offset[0];
|
|
||||||
break;
|
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:
|
case AV_PIX_FMT_YUV420P:
|
||||||
if (data_length <
|
if (data_length <
|
||||||
frame->height * (frame->linesize[0] + frame->linesize[1] / 2 +
|
width_ * frame->height + (width_ / 2) * (frame->height / 2) * 2) {
|
||||||
frame->linesize[2] / 2)) {
|
|
||||||
LOG_ERROR(std::string("fill_frame: 420P data length error. data_length:") +
|
LOG_ERROR(std::string("fill_frame: 420P data length error. data_length:") +
|
||||||
std::to_string(data_length) +
|
std::to_string(data_length) +
|
||||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
", width:" + std::to_string(width_) +
|
||||||
", linesize[1]:" + std::to_string(frame->linesize[1]) +
|
", height:" + std::to_string(frame->height));
|
||||||
", linesize[2]:" + std::to_string(frame->linesize[2]));
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
frame->data[0] = data;
|
|
||||||
frame->data[1] = data + offset[0];
|
|
||||||
frame->data[2] = data + offset[1];
|
|
||||||
break;
|
break;
|
||||||
case AV_PIX_FMT_YUYV422:
|
case AV_PIX_FMT_YUYV422:
|
||||||
case AV_PIX_FMT_YVYU422:
|
case AV_PIX_FMT_YVYU422:
|
||||||
case AV_PIX_FMT_UYVY422:
|
case AV_PIX_FMT_UYVY422:
|
||||||
// Packed YUV 4:2:2 formats: single plane, linesize[0] = width * 2
|
// 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:") +
|
LOG_ERROR(std::string("fill_frame: YUYV422 data length error. data_length:") +
|
||||||
std::to_string(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));
|
", height:" + std::to_string(frame->height));
|
||||||
return -1;
|
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;
|
break;
|
||||||
case AV_PIX_FMT_RGB24:
|
case AV_PIX_FMT_RGB24:
|
||||||
case AV_PIX_FMT_BGR24:
|
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:") +
|
LOG_ERROR(std::string("fill_frame: RGB24/BGR24 data length error. data_length:") +
|
||||||
std::to_string(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));
|
", height:" + std::to_string(frame->height));
|
||||||
return -1;
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
|
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
|
||||||
@@ -479,6 +609,79 @@ private:
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool can_borrow_input(int data_length) const {
|
||||||
|
if (hw_device_type_ != AV_HWDEVICE_TYPE_NONE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (name_.find("mediacodec") == std::string::npos) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
switch (pixfmt_) {
|
||||||
|
case AV_PIX_FMT_NV12:
|
||||||
|
case AV_PIX_FMT_NV21:
|
||||||
|
return data_length >= width_ * height_ * 3 / 2;
|
||||||
|
case AV_PIX_FMT_YUV420P:
|
||||||
|
return data_length >= width_ * height_ * 3 / 2;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AVFrame *wrap_borrowed_frame(const uint8_t *data, int data_length) {
|
||||||
|
if (!borrowed_frame_) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
av_frame_unref(borrowed_frame_);
|
||||||
|
borrowed_frame_->format = pixfmt_;
|
||||||
|
borrowed_frame_->width = width_;
|
||||||
|
borrowed_frame_->height = height_;
|
||||||
|
|
||||||
|
const int y_size = width_ * height_;
|
||||||
|
const int uv_size = y_size / 4;
|
||||||
|
switch (pixfmt_) {
|
||||||
|
case AV_PIX_FMT_NV12:
|
||||||
|
case AV_PIX_FMT_NV21:
|
||||||
|
if (data_length < y_size + y_size / 2) {
|
||||||
|
LOG_ERROR("wrap_borrowed_frame: NV12/NV21 data length error");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
|
||||||
|
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
|
||||||
|
borrowed_frame_->linesize[0] = width_;
|
||||||
|
borrowed_frame_->linesize[1] = width_;
|
||||||
|
break;
|
||||||
|
case AV_PIX_FMT_YUV420P:
|
||||||
|
if (data_length < y_size + uv_size * 2) {
|
||||||
|
LOG_ERROR("wrap_borrowed_frame: YUV420P data length error");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
borrowed_frame_->data[0] = const_cast<uint8_t *>(data);
|
||||||
|
borrowed_frame_->data[1] = const_cast<uint8_t *>(data + y_size);
|
||||||
|
borrowed_frame_->data[2] = const_cast<uint8_t *>(data + y_size + uv_size);
|
||||||
|
borrowed_frame_->linesize[0] = width_;
|
||||||
|
borrowed_frame_->linesize[1] = width_ / 2;
|
||||||
|
borrowed_frame_->linesize[2] = width_ / 2;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return borrowed_frame_;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bytes_per_pixel(int pix_fmt) {
|
||||||
|
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
|
} // 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<AVPacket *>(packet);
|
||||||
|
if (pkt) {
|
||||||
|
av_packet_free(&pkt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" int ffmpeg_ram_set_bitrate(FFmpegRamEncoder *encoder, int kbs) {
|
extern "C" int ffmpeg_ram_set_bitrate(FFmpegRamEncoder *encoder, int kbs) {
|
||||||
try {
|
try {
|
||||||
return encoder->set_bitrate(kbs);
|
return encoder->set_bitrate(kbs);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
|
typedef void (*RamEncodeCallback)(const uint8_t *data, int len, int64_t pts,
|
||||||
int key, const void *obj);
|
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,
|
typedef void (*RamDecodeCallback)(const uint8_t *data, int len, int width,
|
||||||
int height, int pixfmt, const void *obj);
|
int height, int pixfmt, const void *obj);
|
||||||
|
|
||||||
@@ -18,7 +21,11 @@ void *ffmpeg_ram_new_encoder(const char *name, const char *mc_name, int width,
|
|||||||
RamEncodeCallback callback);
|
RamEncodeCallback callback);
|
||||||
int ffmpeg_ram_encode(void *encoder, const uint8_t *data, int length,
|
int ffmpeg_ram_encode(void *encoder, const uint8_t *data, int length,
|
||||||
const void *obj, int64_t ms);
|
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_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 ffmpeg_ram_get_linesize_offset_length(int pix_fmt, int width, int height,
|
||||||
int align, int *linesize, int *offset,
|
int align, int *linesize, int *offset,
|
||||||
int *length);
|
int *length);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ pub enum Driver {
|
|||||||
FFMPEG,
|
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) {
|
pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use std::ffi::c_int;
|
use std::ffi::c_int;
|
||||||
@@ -39,6 +39,8 @@ pub(crate) fn supported_gpu(_encode: bool) -> (bool, bool, bool) {
|
|||||||
linux_support_amd() == 0,
|
linux_support_amd() == 0,
|
||||||
linux_support_intel() == 0,
|
linux_support_intel() == 0,
|
||||||
);
|
);
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
return (false, false, false);
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
(false, false, false)
|
(false, false, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ use crate::{
|
|||||||
common::DataFormat::{self, *},
|
common::DataFormat::{self, *},
|
||||||
ffmpeg::{init_av_log, AVPixelFormat},
|
ffmpeg::{init_av_log, AVPixelFormat},
|
||||||
ffmpeg_ram::{
|
ffmpeg_ram::{
|
||||||
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_free_encoder,
|
ffmpeg_linesize_offset_length, ffmpeg_ram_encode, ffmpeg_ram_encode_packet,
|
||||||
ffmpeg_ram_new_encoder, ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo,
|
ffmpeg_ram_free_encoder, ffmpeg_ram_free_packet, ffmpeg_ram_new_encoder,
|
||||||
AV_NUM_DATA_POINTERS,
|
ffmpeg_ram_request_keyframe, ffmpeg_ram_set_bitrate, CodecInfo, AV_NUM_DATA_POINTERS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "bytes")]
|
||||||
|
use bytes::Bytes;
|
||||||
use log::trace;
|
use log::trace;
|
||||||
use std::{
|
use std::{
|
||||||
ffi::{c_void, CString},
|
ffi::{c_void, CString},
|
||||||
@@ -15,7 +17,7 @@ use std::{
|
|||||||
slice,
|
slice,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
|
||||||
use crate::common::Driver;
|
use crate::common::Driver;
|
||||||
|
|
||||||
/// Timeout for encoder test in milliseconds
|
/// Timeout for encoder test in milliseconds
|
||||||
@@ -26,6 +28,7 @@ const PRIORITY_AMF: i32 = 2;
|
|||||||
const PRIORITY_RKMPP: i32 = 3;
|
const PRIORITY_RKMPP: i32 = 3;
|
||||||
const PRIORITY_VAAPI: i32 = 4;
|
const PRIORITY_VAAPI: i32 = 4;
|
||||||
const PRIORITY_V4L2M2M: i32 = 5;
|
const PRIORITY_V4L2M2M: i32 = 5;
|
||||||
|
const PRIORITY_MEDIACODEC: i32 = 2;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct CandidateCodecSpec {
|
struct CandidateCodecSpec {
|
||||||
@@ -92,11 +95,32 @@ fn linux_support_v4l2m2m() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
#[cfg(any(windows, target_os = "linux", target_os = "android"))]
|
||||||
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
|
fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
||||||
let mut codecs = Vec::new();
|
let mut codecs = Vec::new();
|
||||||
|
|
||||||
|
if cfg!(target_os = "android") {
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "h264_mediacodec",
|
||||||
|
format: H264,
|
||||||
|
priority: PRIORITY_MEDIACODEC,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
push_candidate(
|
||||||
|
&mut codecs,
|
||||||
|
CandidateCodecSpec {
|
||||||
|
name: "hevc_mediacodec",
|
||||||
|
format: H265,
|
||||||
|
priority: PRIORITY_MEDIACODEC,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return codecs;
|
||||||
|
}
|
||||||
|
|
||||||
let contains = |_vendor: Driver, _format: DataFormat| {
|
let contains = |_vendor: Driver, _format: DataFormat| {
|
||||||
// Without VRAM feature, we can't check SDK availability.
|
// Without VRAM feature, we can't check SDK availability.
|
||||||
// Keep the prefilter coarse and let FFmpeg validation do the real check.
|
// Keep the prefilter coarse and let FFmpeg validation do the real check.
|
||||||
@@ -257,7 +281,13 @@ struct ProbePolicy {
|
|||||||
|
|
||||||
impl ProbePolicy {
|
impl ProbePolicy {
|
||||||
fn for_codec(codec_name: &str) -> Self {
|
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 {
|
Self {
|
||||||
max_attempts: 5,
|
max_attempts: 5,
|
||||||
request_keyframe: true,
|
request_keyframe: true,
|
||||||
@@ -304,11 +334,11 @@ fn log_failed_probe_attempt(
|
|||||||
frames: &[EncodeFrame],
|
frames: &[EncodeFrame],
|
||||||
elapsed_ms: u128,
|
elapsed_ms: u128,
|
||||||
) {
|
) {
|
||||||
use log::debug;
|
use log::{debug, trace};
|
||||||
|
|
||||||
if policy.accept_any_output {
|
if policy.accept_any_output {
|
||||||
if frames.is_empty() {
|
if frames.is_empty() {
|
||||||
debug!(
|
trace!(
|
||||||
"Encoder {} test produced no output on attempt {}",
|
"Encoder {} test produced no output on attempt {}",
|
||||||
codec_name, attempt
|
codec_name, attempt
|
||||||
);
|
);
|
||||||
@@ -337,7 +367,7 @@ fn log_failed_probe_attempt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
|
fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> bool {
|
||||||
use log::debug;
|
use log::{debug, warn};
|
||||||
|
|
||||||
debug!("Testing encoder: {}", codec.name);
|
debug!("Testing encoder: {}", codec.name);
|
||||||
|
|
||||||
@@ -388,13 +418,13 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
last_err = Some(err);
|
last_err = Some(err);
|
||||||
debug!(
|
warn!(
|
||||||
"Encoder {} test attempt {} returned error: {}",
|
"Encoder {} test attempt {} returned error: {}",
|
||||||
codec.name, attempt_no, err
|
codec.name, attempt_no, err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,16 +437,20 @@ fn validate_candidate(codec: &CodecInfo, ctx: &EncodeContext, yuv: &[u8]) -> boo
|
|||||||
);
|
);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
debug!("Failed to create encoder {}", codec.name);
|
warn!("Failed to create encoder {}", codec.name);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
|
fn add_software_fallback(codecs: &mut Vec<CodecInfo>) {
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
||||||
|
if cfg!(target_os = "android") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for fallback in CodecInfo::soft().into_vec() {
|
for fallback in CodecInfo::soft().into_vec() {
|
||||||
if !codecs.iter().any(|codec| codec.format == fallback.format) {
|
if !codecs.iter().any(|codec| codec.format == fallback.format) {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -451,6 +485,39 @@ pub struct EncodeFrame {
|
|||||||
pub key: i32,
|
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 {
|
impl Display for EncodeFrame {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "encode len:{}, pts:{}", self.data.len(), self.pts)
|
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<Vec<EncodeBytesFrame>, i32> {
|
||||||
|
unsafe {
|
||||||
|
let mut frames = Vec::<EncodeBytesFrame>::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) {
|
extern "C" fn callback(data: *const u8, size: c_int, pts: i64, key: i32, obj: *const c_void) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let frames = &mut *(obj as *mut Vec<EncodeFrame>);
|
let frames = &mut *(obj as *mut Vec<EncodeFrame>);
|
||||||
@@ -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<EncodeBytesFrame>);
|
||||||
|
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<(), ()> {
|
pub fn set_bitrate(&mut self, kbs: i32) -> Result<(), ()> {
|
||||||
let ret = unsafe { ffmpeg_ram_set_bitrate(self.codec, kbs) };
|
let ret = unsafe { ffmpeg_ram_set_bitrate(self.codec, kbs) };
|
||||||
if ret == 0 {
|
if ret == 0 {
|
||||||
@@ -588,11 +698,11 @@ impl Encoder {
|
|||||||
pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> {
|
pub fn available_encoders(ctx: EncodeContext, _sdk: Option<String>) -> Vec<CodecInfo> {
|
||||||
use log::debug;
|
use log::debug;
|
||||||
|
|
||||||
if !(cfg!(windows) || cfg!(target_os = "linux")) {
|
if !(cfg!(windows) || cfg!(target_os = "linux") || cfg!(target_os = "android")) {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let mut res = 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);
|
let codecs = enumerate_candidate_codecs(&ctx);
|
||||||
|
|
||||||
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
|
if let Ok(yuv) = Encoder::dummy_yuv(ctx.clone()) {
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ use std::ffi::c_int;
|
|||||||
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ram_ffi.rs"));
|
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;
|
pub mod decode;
|
||||||
|
|
||||||
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder
|
// Provide a small stub on non-ARM builds so dependents can still compile, but decoder
|
||||||
// construction will fail (since the C++ RKMPP decoder isn't built/linked).
|
// construction will fail (since the C++ RKMPP decoder isn't built/linked).
|
||||||
#[cfg(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 {
|
pub mod decode {
|
||||||
use crate::ffmpeg::AVPixelFormat;
|
use crate::ffmpeg::AVPixelFormat;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
pub mod capture;
|
pub mod capture;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod ffmpeg;
|
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_hw;
|
||||||
pub mod ffmpeg_ram;
|
pub mod ffmpeg_ram;
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,5 @@ impl bindgen::callbacks::ParseCallbacks for Fix753 {
|
|||||||
fn v4l2r_bindgen_builder(builder: bindgen::Builder) -> bindgen::Builder {
|
fn v4l2r_bindgen_builder(builder: bindgen::Builder) -> bindgen::Builder {
|
||||||
builder
|
builder
|
||||||
.parse_callbacks(Box::new(Fix753))
|
.parse_callbacks(Box::new(Fix753))
|
||||||
.derive_partialeq(true)
|
|
||||||
.derive_eq(true)
|
|
||||||
.derive_default(true)
|
.derive_default(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ license = "BSD-3-Clause"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
bindgen = "0.59"
|
bindgen = "0.70.1"
|
||||||
|
|||||||
@@ -19,9 +19,19 @@ fn main() {
|
|||||||
|
|
||||||
fn generate_bindings(cpp_dir: &Path) {
|
fn generate_bindings(cpp_dir: &Path) {
|
||||||
let ffi_header = cpp_dir.join("yuv_ffi.h");
|
let ffi_header = cpp_dir.join("yuv_ffi.h");
|
||||||
|
let mut builder = bindgen::builder().header(ffi_header.to_string_lossy().to_string());
|
||||||
|
|
||||||
bindgen::builder()
|
if env::var("CARGO_CFG_TARGET_OS").ok().as_deref() == Some("android") {
|
||||||
.header(ffi_header.to_string_lossy().to_string())
|
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
|
// YUYV conversions
|
||||||
.allowlist_function("YUY2ToI420")
|
.allowlist_function("YUY2ToI420")
|
||||||
.allowlist_function("YUY2ToNV12")
|
.allowlist_function("YUY2ToNV12")
|
||||||
@@ -38,6 +48,7 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
// NV12/NV21 conversions
|
// NV12/NV21 conversions
|
||||||
.allowlist_function("NV12ToI420")
|
.allowlist_function("NV12ToI420")
|
||||||
.allowlist_function("NV21ToI420")
|
.allowlist_function("NV21ToI420")
|
||||||
|
.allowlist_function("NV21ToNV12")
|
||||||
.allowlist_function("NV12Copy")
|
.allowlist_function("NV12Copy")
|
||||||
.allowlist_function("SplitUVPlane")
|
.allowlist_function("SplitUVPlane")
|
||||||
// ARGB/BGRA conversions
|
// ARGB/BGRA conversions
|
||||||
@@ -49,6 +60,7 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
.allowlist_function("ABGRToARGB")
|
.allowlist_function("ABGRToARGB")
|
||||||
// RGB24/BGR24 conversions
|
// RGB24/BGR24 conversions
|
||||||
.allowlist_function("RGB24ToI420")
|
.allowlist_function("RGB24ToI420")
|
||||||
|
.allowlist_function("RGB24ToNV12")
|
||||||
.allowlist_function("RAWToI420")
|
.allowlist_function("RAWToI420")
|
||||||
.allowlist_function("RGB24ToARGB")
|
.allowlist_function("RGB24ToARGB")
|
||||||
.allowlist_function("RAWToARGB")
|
.allowlist_function("RAWToARGB")
|
||||||
@@ -62,6 +74,9 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
.allowlist_function("UYVYToARGB")
|
.allowlist_function("UYVYToARGB")
|
||||||
.allowlist_function("ARGBToRGB24")
|
.allowlist_function("ARGBToRGB24")
|
||||||
.allowlist_function("ARGBToRAW")
|
.allowlist_function("ARGBToRAW")
|
||||||
|
// MJPEG decoding
|
||||||
|
.allowlist_function("MJPGToNV12")
|
||||||
|
.allowlist_function("MJPGSize")
|
||||||
// Scaling
|
// Scaling
|
||||||
.allowlist_function("I420Scale")
|
.allowlist_function("I420Scale")
|
||||||
.allowlist_function("NV12Scale")
|
.allowlist_function("NV12Scale")
|
||||||
@@ -81,6 +96,30 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn link_libyuv() {
|
fn link_libyuv() {
|
||||||
|
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
|
||||||
|
if target_os == "android" {
|
||||||
|
if link_android_libyuv() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(vcpkg_installed) = vcpkg_installed_root() {
|
||||||
|
if link_vcpkg(vcpkg_installed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!(
|
||||||
|
"Android libyuv not found!\n\
|
||||||
|
\n\
|
||||||
|
Build it with scripts/build-android-libyuv.sh and set:\n\
|
||||||
|
export ONE_KVM_ANDROID_LIBYUV_ROOT=/path/to/android-libyuv\n\
|
||||||
|
\n\
|
||||||
|
Expected layout:\n\
|
||||||
|
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/include\n\
|
||||||
|
$ONE_KVM_ANDROID_LIBYUV_ROOT/<abi>/lib/libyuv.a"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Try vcpkg first
|
// Try vcpkg first
|
||||||
if let Some(vcpkg_installed) = vcpkg_installed_root() {
|
if let Some(vcpkg_installed) = vcpkg_installed_root() {
|
||||||
if link_vcpkg(vcpkg_installed) {
|
if link_vcpkg(vcpkg_installed) {
|
||||||
@@ -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<String> {
|
||||||
|
let ndk = android_ndk_home();
|
||||||
|
let target = env::var("TARGET").unwrap_or_default();
|
||||||
|
let toolchain = ndk.join("toolchains/llvm/prebuilt").join(host_tag());
|
||||||
|
let sysroot = toolchain.join("sysroot");
|
||||||
|
let clang_include = toolchain
|
||||||
|
.join("lib/clang")
|
||||||
|
.join(clang_version(&toolchain))
|
||||||
|
.join("include");
|
||||||
|
let api = env::var("CARGO_NDK_PLATFORM")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
.unwrap_or(21);
|
||||||
|
let clang_target = android_clang_target(&target);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
format!("--target={clang_target}"),
|
||||||
|
format!("--sysroot={}", sysroot.display()),
|
||||||
|
format!("-D__ANDROID_API__={api}"),
|
||||||
|
format!("-isystem{}", clang_include.display()),
|
||||||
|
format!("-isystem{}", sysroot.join("usr/include").display()),
|
||||||
|
format!(
|
||||||
|
"-isystem{}",
|
||||||
|
sysroot.join("usr/include").join(clang_target).display()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_clang_target(target: &str) -> &'static str {
|
||||||
|
match target {
|
||||||
|
"aarch64-linux-android" => "aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi" => "armv7a-linux-androideabi",
|
||||||
|
"i686-linux-android" => "i686-linux-android",
|
||||||
|
"x86_64-linux-android" => "x86_64-linux-android",
|
||||||
|
other => panic!("unsupported Android target for libyuv bindgen: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn android_ndk_home() -> PathBuf {
|
||||||
|
for key in ["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "NDK_HOME"] {
|
||||||
|
if let Ok(value) = env::var(key) {
|
||||||
|
return PathBuf::from(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
|
||||||
|
if let Ok(value) = env::var(key) {
|
||||||
|
let ndk_dir = PathBuf::from(value).join("ndk");
|
||||||
|
if let Some(newest) = newest_child_dir(&ndk_dir) {
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic!(
|
||||||
|
"libyuv Android bindgen requires ANDROID_NDK_HOME, ANDROID_NDK_ROOT, NDK_HOME, \
|
||||||
|
or ANDROID_HOME/ANDROID_SDK_ROOT with an ndk directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_child_dir(path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut entries = std::fs::read_dir(path)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.is_dir())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
entries.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_tag() -> &'static str {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
"linux-x86_64"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"darwin-x86_64"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"windows-x86_64"
|
||||||
|
} else {
|
||||||
|
panic!("unsupported host OS for Android NDK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clang_version(toolchain: &Path) -> String {
|
||||||
|
let clang_dir = toolchain.join("lib/clang");
|
||||||
|
let mut entries = std::fs::read_dir(&clang_dir)
|
||||||
|
.unwrap_or_else(|_| panic!("missing NDK clang directory: {}", clang_dir.display()))
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
entries
|
||||||
|
.pop()
|
||||||
|
.unwrap_or_else(|| panic!("no clang versions found under: {}", clang_dir.display()))
|
||||||
|
}
|
||||||
|
|
||||||
fn vcpkg_installed_root() -> Option<PathBuf> {
|
fn vcpkg_installed_root() -> Option<PathBuf> {
|
||||||
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
|
println!("cargo:rerun-if-env-changed=VCPKG_INSTALLED_DIR");
|
||||||
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
|
println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
|
||||||
@@ -133,6 +383,10 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
|
|||||||
("linux", "x86_64") => "x64-linux",
|
("linux", "x86_64") => "x64-linux",
|
||||||
("linux", "aarch64") => "arm64-linux",
|
("linux", "aarch64") => "arm64-linux",
|
||||||
("linux", "arm") => "arm-linux",
|
("linux", "arm") => "arm-linux",
|
||||||
|
("android", "x86_64") => "x64-android",
|
||||||
|
("android", "x86") => "x86-android",
|
||||||
|
("android", "aarch64") => "arm64-android",
|
||||||
|
("android", "arm") => "arm-neon-android",
|
||||||
("windows", "x86_64") => "x64-windows-static",
|
("windows", "x86_64") => "x64-windows-static",
|
||||||
("windows", "x86") => "x86-windows-static",
|
("windows", "x86") => "x86-windows-static",
|
||||||
("macos", "x86_64") => "x64-osx",
|
("macos", "x86_64") => "x64-osx",
|
||||||
@@ -169,14 +423,21 @@ fn link_vcpkg(mut path: PathBuf) -> bool {
|
|||||||
if use_static && static_lib.exists() {
|
if use_static && static_lib.exists() {
|
||||||
// Static linking (for deb packaging)
|
// Static linking (for deb packaging)
|
||||||
println!("cargo:rustc-link-lib=static=yuv");
|
println!("cargo:rustc-link-lib=static=yuv");
|
||||||
#[cfg(target_os = "linux")]
|
link_libjpeg_for_static_libyuv(&[lib_path.clone()], &target_os);
|
||||||
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 (static linking)");
|
println!("cargo:info=Using libyuv from vcpkg (static linking)");
|
||||||
} else {
|
} else {
|
||||||
// Dynamic linking (default for development)
|
// Dynamic linking (default for development)
|
||||||
println!("cargo:rustc-link-lib=yuv");
|
println!("cargo:rustc-link-lib=yuv");
|
||||||
#[cfg(target_os = "linux")]
|
if target_os == "linux" {
|
||||||
println!("cargo:rustc-link-lib=stdc++");
|
println!("cargo:rustc-link-lib=stdc++");
|
||||||
|
} else if target_os == "android" {
|
||||||
|
println!("cargo:rustc-link-lib=c++_shared");
|
||||||
|
}
|
||||||
println!("cargo:info=Using libyuv from vcpkg (dynamic linking)");
|
println!("cargo:info=Using libyuv from vcpkg (dynamic linking)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +529,7 @@ fn link_system() -> bool {
|
|||||||
if use_static && libyuv_static.exists() {
|
if use_static && libyuv_static.exists() {
|
||||||
println!("cargo:rustc-link-search=native={}", path);
|
println!("cargo:rustc-link-search=native={}", path);
|
||||||
println!("cargo:rustc-link-lib=static=yuv");
|
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:rustc-link-lib=stdc++");
|
||||||
println!(
|
println!(
|
||||||
"cargo:info=Using system libyuv from {} (static linking)",
|
"cargo:info=Using system libyuv from {} (static linking)",
|
||||||
@@ -294,3 +556,58 @@ fn link_system() -> bool {
|
|||||||
|
|
||||||
false
|
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<PathBuf>) -> Vec<PathBuf> {
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
if !deduped.iter().any(|existing| existing == &path) {
|
||||||
|
deduped.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ int NV21ToI420(const uint8_t* src_y, int src_stride_y,
|
|||||||
uint8_t* dst_v, int dst_stride_v,
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
int width, int height);
|
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
|
// Split interleaved UV plane into separate U and V planes
|
||||||
void SplitUVPlane(const uint8_t* src_uv, int src_stride_uv,
|
void SplitUVPlane(const uint8_t* src_uv, int src_stride_uv,
|
||||||
uint8_t* dst_u, int dst_stride_u,
|
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,
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
int width, int height);
|
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
|
// RGB24 -> ARGB
|
||||||
int RGB24ToARGB(const uint8_t* src_rgb24, int src_stride_rgb24,
|
int RGB24ToARGB(const uint8_t* src_rgb24, int src_stride_rgb24,
|
||||||
uint8_t* dst_argb, int dst_stride_argb,
|
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 src_width, int src_height,
|
||||||
int dst_width, int dst_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
|
// Get MJPEG dimensions without decoding
|
||||||
int MJPGSize(const uint8_t* sample, size_t sample_size,
|
int MJPGSize(const uint8_t* sample, size_t sample_size,
|
||||||
int* width, int* height);
|
int* width, int* height);
|
||||||
|
|||||||
@@ -32,17 +32,9 @@ use std::fmt;
|
|||||||
// Include auto-generated FFI bindings
|
// Include auto-generated FFI bindings
|
||||||
include!(concat!(env!("OUT_DIR"), "/yuv_ffi.rs"));
|
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]
|
#[inline]
|
||||||
fn usize_to_size_t(val: usize) -> SizeT {
|
fn usize_to_size_t(val: usize) -> usize {
|
||||||
val as SizeT
|
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)
|
// ARGB/BGRA conversions (32-bit)
|
||||||
// Note: libyuv ARGB = BGRA in memory on little-endian systems
|
// 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)
|
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<()> {
|
pub fn bgr24_to_nv12(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
||||||
if width % 2 != 0 || height % 2 != 0 {
|
if width % 2 != 0 || height % 2 != 0 {
|
||||||
return Err(YuvError::InvalidDimensions);
|
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);
|
return Err(YuvError::BufferTooSmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-step conversion: BGR24 → I420 → NV12
|
#[cfg(windows)]
|
||||||
let mut i420_buffer = vec![0u8; i420_size(w, h)];
|
{
|
||||||
bgr24_to_i420(src, &mut i420_buffer, width, height)?;
|
let mut i420_buffer = vec![0u8; i420_size(w, h)];
|
||||||
i420_to_nv12(&i420_buffer, dst, width, height)
|
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,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
219
scripts/build-android-alsa.sh
Normal file
219
scripts/build-android-alsa.sh
Normal file
@@ -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 <dir> Existing alsa-lib source checkout. If omitted, the
|
||||||
|
script clones it into .tmp/android-alsa-src.
|
||||||
|
--output <dir> Output root. Default: dist/android-alsa
|
||||||
|
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
|
||||||
|
--api <level> Android API level. Default: 21.
|
||||||
|
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The output layout is compatible with ONE_KVM_ANDROID_ALSA_ROOT:
|
||||||
|
<output>/arm64-v8a/include/alsa/asoundlib.h
|
||||||
|
<output>/arm64-v8a/lib/libasound.so
|
||||||
|
<output>/arm64-v8a/lib/pkgconfig/alsa.pc
|
||||||
|
<output>/armeabi-v7a/include/alsa/asoundlib.h
|
||||||
|
<output>/armeabi-v7a/lib/libasound.so
|
||||||
|
<output>/armeabi-v7a/lib/pkgconfig/alsa.pc
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--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" <<EOF
|
||||||
|
prefix=\${pcfiledir}/../..
|
||||||
|
exec_prefix=\${prefix}
|
||||||
|
libdir=\${exec_prefix}/lib
|
||||||
|
includedir=\${prefix}/include
|
||||||
|
|
||||||
|
Name: alsa
|
||||||
|
Description: ALSA sound library
|
||||||
|
Version: 1.2.15
|
||||||
|
Libs: -L\${libdir} -lasound
|
||||||
|
Cflags: -I\${includedir}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Built ALSA for ${abi}: ${prefix}"
|
||||||
|
}
|
||||||
|
|
||||||
|
for abi in $(normalize_abis); do
|
||||||
|
build_one "$abi"
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use this when building the Android APK:
|
||||||
|
export ONE_KVM_ANDROID_ALSA_ROOT="${OUTPUT_DIR}"
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
296
scripts/build-android-ffmpeg-mediacodec.sh
Normal file
296
scripts/build-android-ffmpeg-mediacodec.sh
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
#!/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-ffmpeg-mediacodec"
|
||||||
|
ANDROID_API="${ANDROID_API:-21}"
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||||
|
BUILD_ABIS="arm64-v8a armeabi-v7a"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/build-android-ffmpeg-mediacodec.sh --source <ffmpeg-source-dir> [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--source <dir> FFmpeg source directory. For the downloaded package,
|
||||||
|
use the extracted ffmpeg-rockchip directory.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--output <dir> Output root. Default: dist/android-ffmpeg-mediacodec
|
||||||
|
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
|
||||||
|
--api <level> Android API level. Default: 21.
|
||||||
|
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The output layout is compatible with ONE_KVM_ANDROID_FFMPEG_ROOT:
|
||||||
|
<output>/arm64-v8a/include
|
||||||
|
<output>/arm64-v8a/lib
|
||||||
|
<output>/armeabi-v7a/include
|
||||||
|
<output>/armeabi-v7a/lib
|
||||||
|
|
||||||
|
Example:
|
||||||
|
scripts/build-android-ffmpeg-mediacodec.sh \
|
||||||
|
--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 <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use this when building the Android APK:
|
||||||
|
export ONE_KVM_ANDROID_FFMPEG_ROOT="${OUTPUT_DIR}"
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
189
scripts/build-android-libyuv.sh
Normal file
189
scripts/build-android-libyuv.sh
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
#!/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-libyuv"
|
||||||
|
JPEG_ROOT="${ONE_KVM_ANDROID_TURBOJPEG_ROOT:-${PROJECT_ROOT}/dist/android-turbojpeg}"
|
||||||
|
ANDROID_API="${ANDROID_API:-21}"
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||||
|
BUILD_ABIS="arm64-v8a armeabi-v7a"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
LIBYUV_REPO="${LIBYUV_REPO:-https://github.com/lemenkov/libyuv.git}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/build-android-libyuv.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--source <dir> Existing libyuv source checkout. If omitted, the script
|
||||||
|
clones libyuv into .tmp/android-libyuv-src.
|
||||||
|
--output <dir> Output root. Default: dist/android-libyuv
|
||||||
|
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
|
||||||
|
--api <level> Android API level. Default: 21.
|
||||||
|
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
|
||||||
|
--jpeg-root <dir> Android libjpeg root. Defaults to ONE_KVM_ANDROID_TURBOJPEG_ROOT
|
||||||
|
or dist/android-turbojpeg when present. Enables libyuv HAVE_JPEG.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The output layout is compatible with ONE_KVM_ANDROID_LIBYUV_ROOT:
|
||||||
|
<output>/arm64-v8a/include
|
||||||
|
<output>/arm64-v8a/lib/libyuv.a
|
||||||
|
<output>/armeabi-v7a/include
|
||||||
|
<output>/armeabi-v7a/lib/libyuv.a
|
||||||
|
|
||||||
|
Example:
|
||||||
|
scripts/build-android-libyuv.sh --output /opt/one-kvm/android-libyuv
|
||||||
|
|
||||||
|
export ONE_KVM_ANDROID_LIBYUV_ROOT=/opt/one-kvm/android-libyuv
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--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 <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use this when building the Android APK:
|
||||||
|
export ONE_KVM_ANDROID_LIBYUV_ROOT="${OUTPUT_DIR}"
|
||||||
|
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${JPEG_ROOT}"
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
186
scripts/build-android-opus.sh
Normal file
186
scripts/build-android-opus.sh
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/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-opus"
|
||||||
|
ANDROID_API="${ANDROID_API:-21}"
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||||
|
BUILD_ABIS="arm64-v8a armeabi-v7a"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
OPUS_VERSION="${OPUS_VERSION:-1.5.2}"
|
||||||
|
OPUS_TARBALL_URL="${OPUS_TARBALL_URL:-https://downloads.xiph.org/releases/opus/opus-${OPUS_VERSION}.tar.gz}"
|
||||||
|
OPUS_TARBALL_SHA256="${OPUS_TARBALL_SHA256:-65c1d2f78b9f2fb20082c38cbe47c951ad5839345876e46941612ee87f9a7ce1}"
|
||||||
|
LOCAL_OPUS_TARBALL="${LOCAL_OPUS_TARBALL:-${PROJECT_ROOT}/opus-${OPUS_VERSION}.tar.gz}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/build-android-opus.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--source <dir> Existing opus source checkout. If omitted, the script
|
||||||
|
downloads and extracts the official source tarball
|
||||||
|
into .tmp/android-opus-src.
|
||||||
|
--output <dir> Output root. Default: dist/android-opus
|
||||||
|
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
|
||||||
|
--api <level> Android API level. Default: 21.
|
||||||
|
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The output layout is compatible with ONE_KVM_ANDROID_OPUS_ROOT:
|
||||||
|
<output>/arm64-v8a/include/opus/opus.h
|
||||||
|
<output>/arm64-v8a/lib/libopus.so
|
||||||
|
<output>/armeabi-v7a/include/opus/opus.h
|
||||||
|
<output>/armeabi-v7a/lib/libopus.so
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--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 <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use this when building the Android APK:
|
||||||
|
export ONE_KVM_ANDROID_OPUS_ROOT="${OUTPUT_DIR}"
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
178
scripts/build-android-turbojpeg.sh
Normal file
178
scripts/build-android-turbojpeg.sh
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/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-turbojpeg"
|
||||||
|
ANDROID_API="${ANDROID_API:-21}"
|
||||||
|
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||||
|
BUILD_ABIS="arm64-v8a armeabi-v7a"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
LIBJPEG_TURBO_REPO="${LIBJPEG_TURBO_REPO:-https://github.com/libjpeg-turbo/libjpeg-turbo.git}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/build-android-turbojpeg.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--source <dir> Existing libjpeg-turbo source checkout. If omitted,
|
||||||
|
the script clones it into .tmp/android-turbojpeg-src.
|
||||||
|
--output <dir> Output root. Default: dist/android-turbojpeg
|
||||||
|
--ndk <dir> Android NDK root. Defaults to ANDROID_NDK_HOME or ANDROID_NDK_ROOT.
|
||||||
|
--api <level> Android API level. Default: 21.
|
||||||
|
--abis <list> Space/comma separated ABI list. Default: arm64-v8a armeabi-v7a.
|
||||||
|
-h, --help Show this help.
|
||||||
|
|
||||||
|
The output layout is compatible with ONE_KVM_ANDROID_TURBOJPEG_ROOT:
|
||||||
|
<output>/arm64-v8a/include/turbojpeg.h
|
||||||
|
<output>/arm64-v8a/lib/libturbojpeg.a
|
||||||
|
<output>/arm64-v8a/include/jpeglib.h
|
||||||
|
<output>/arm64-v8a/lib/libjpeg.a
|
||||||
|
<output>/armeabi-v7a/include/turbojpeg.h
|
||||||
|
<output>/armeabi-v7a/lib/libturbojpeg.a
|
||||||
|
<output>/armeabi-v7a/include/jpeglib.h
|
||||||
|
<output>/armeabi-v7a/lib/libjpeg.a
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--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 <<EOF
|
||||||
|
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use this when building the Android APK:
|
||||||
|
export ONE_KVM_ANDROID_TURBOJPEG_ROOT="${OUTPUT_DIR}"
|
||||||
|
cd android && ./gradlew :app:assembleDebug
|
||||||
|
EOF
|
||||||
@@ -85,12 +85,10 @@ impl AtxController {
|
|||||||
shared_serial,
|
shared_serial,
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let executor = AtxKeyExecutor::new_with_shared_serial(
|
let executor =
|
||||||
config.clone(),
|
AtxKeyExecutor::new_with_shared_serial(config.clone(), serial);
|
||||||
serial,
|
*slot =
|
||||||
);
|
Self::init_key_executor(warn_label, info_label, config, executor).await;
|
||||||
*slot = Self::init_key_executor(warn_label, info_label, config, executor)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -102,13 +100,22 @@ impl AtxController {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (slot, warn_label, info_label, config) in [
|
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() {
|
if config.is_configured() {
|
||||||
let executor = AtxKeyExecutor::new(config.clone());
|
let executor = AtxKeyExecutor::new(config.clone());
|
||||||
*slot = Self::init_key_executor(warn_label, info_label, config, executor)
|
*slot = Self::init_key_executor(warn_label, info_label, config, executor).await;
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,11 +236,13 @@ impl AtxController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let Some(executor) = executor else {
|
let Some(executor) = executor else {
|
||||||
return Err(AppError::Config(match action {
|
return Err(AppError::Config(
|
||||||
AtxAction::Reset => "Reset button not configured for ATX controller",
|
match action {
|
||||||
_ => "Power button not configured for ATX controller",
|
AtxAction::Reset => "Reset button not configured for ATX controller",
|
||||||
}
|
_ => "Power button not configured for ATX controller",
|
||||||
.to_string()));
|
}
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
executor.pulse(duration).await?;
|
executor.pulse(duration).await?;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl HidrawLinuxRelayBackend {
|
|||||||
device: &File,
|
device: &File,
|
||||||
report: &[u8; USB_RELAY_REPORT_LEN],
|
report: &[u8; USB_RELAY_REPORT_LEN],
|
||||||
) -> std::io::Result<()> {
|
) -> 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 {
|
if rc < 0 {
|
||||||
Err(std::io::Error::last_os_error())
|
Err(std::io::Error::last_os_error())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ pub fn discover_devices() -> AtxDevices {
|
|||||||
let name_str = name.to_string_lossy();
|
let name_str = name.to_string_lossy();
|
||||||
if name_str.starts_with("gpiochip") {
|
if name_str.starts_with("gpiochip") {
|
||||||
devices.gpio_chips.push(format!("/dev/{}", name_str));
|
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));
|
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));
|
devices.serial_ports.push(format!("/dev/{}", name_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(feature = "android")))]
|
||||||
#[path = "capture_linux.rs"]
|
#[path = "capture_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
|
#[cfg(feature = "android")]
|
||||||
|
#[path = "capture_android.rs"]
|
||||||
|
mod imp;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[path = "capture_windows.rs"]
|
#[path = "capture_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|||||||
292
src/audio/capture_android.rs
Normal file
292
src/audio/capture_android.rs
Normal file
@@ -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<watch::Sender<CaptureState>>,
|
||||||
|
state_rx: watch::Receiver<CaptureState>,
|
||||||
|
frame_tx: broadcast::Sender<AudioFrame>,
|
||||||
|
stop_flag: Arc<AtomicBool>,
|
||||||
|
sequence: Arc<AtomicU64>,
|
||||||
|
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
|
log_throttler: LogThrottler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioCapturer {
|
||||||
|
pub fn new(config: AudioConfig) -> Self {
|
||||||
|
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
||||||
|
let (frame_tx, _) = broadcast::channel(16);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state: Arc::new(state_tx),
|
||||||
|
state_rx,
|
||||||
|
frame_tx,
|
||||||
|
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
sequence: Arc::new(AtomicU64::new(0)),
|
||||||
|
capture_handle: Mutex::new(None),
|
||||||
|
log_throttler: LogThrottler::with_secs(5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> CaptureState {
|
||||||
|
*self.state_rx.borrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||||
|
self.state_rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||||
|
self.frame_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<()> {
|
||||||
|
if self.state() == CaptureState::Running {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Starting audio capture on {} at {}Hz {}ch",
|
||||||
|
self.config.device_name, self.config.sample_rate, self.config.channels
|
||||||
|
);
|
||||||
|
|
||||||
|
self.stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let config = self.config.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
let frame_tx = self.frame_tx.clone();
|
||||||
|
let stop_flag = self.stop_flag.clone();
|
||||||
|
let sequence = self.sequence.clone();
|
||||||
|
let log_throttler = self.log_throttler.clone();
|
||||||
|
|
||||||
|
let handle = tokio::task::spawn_blocking(move || {
|
||||||
|
let result = run_capture(
|
||||||
|
&config,
|
||||||
|
&state,
|
||||||
|
&frame_tx,
|
||||||
|
&stop_flag,
|
||||||
|
&sequence,
|
||||||
|
&log_throttler,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
|
||||||
|
let _ = state.send(CaptureState::Error);
|
||||||
|
} else {
|
||||||
|
let _ = state.send(CaptureState::Stopped);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
*self.capture_handle.lock().await = Some(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
info!("Stopping audio capture");
|
||||||
|
self.stop_flag.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if let Some(handle) = self.capture_handle.lock().await.take() {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = self.state.send(CaptureState::Stopped);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.state() == CaptureState::Running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_capture(
|
||||||
|
config: &AudioConfig,
|
||||||
|
state: &watch::Sender<CaptureState>,
|
||||||
|
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||||
|
stop_flag: &AtomicBool,
|
||||||
|
sequence: &AtomicU64,
|
||||||
|
log_throttler: &LogThrottler,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
|
||||||
|
AppError::AudioError(format!(
|
||||||
|
"Failed to open audio device {}: {}",
|
||||||
|
config.device_name, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let hwp = HwParams::any(&pcm)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
||||||
|
|
||||||
|
hwp.set_channels(config.channels)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
||||||
|
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
||||||
|
hwp.set_format(Format::s16())
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
||||||
|
hwp.set_access(Access::RWInterleaved)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
||||||
|
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
||||||
|
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
||||||
|
pcm.hw_params(&hwp)
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hw_now = pcm.hw_params_current().map_err(|e| {
|
||||||
|
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
|
||||||
|
})?;
|
||||||
|
let actual_rate = hw_now
|
||||||
|
.get_rate()
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
|
||||||
|
let actual_ch = hw_now
|
||||||
|
.get_channels()
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
|
||||||
|
if actual_rate != 48_000 {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio capture requires 48000 Hz; device is {} Hz",
|
||||||
|
actual_rate
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if actual_ch != 2 {
|
||||||
|
return Err(AppError::AudioError(format!(
|
||||||
|
"Audio capture requires 2 channels (stereo); device has {}",
|
||||||
|
actual_ch
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
debug!("Audio capture: 48000 Hz, 2 ch");
|
||||||
|
|
||||||
|
pcm.prepare()
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
||||||
|
let _ = state.send(CaptureState::Running);
|
||||||
|
|
||||||
|
let period_frames = pcm
|
||||||
|
.hw_params_current()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.get_period_size().ok())
|
||||||
|
.map(|f| f as usize)
|
||||||
|
.unwrap_or(1024)
|
||||||
|
.max(256);
|
||||||
|
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
||||||
|
let io = pcm
|
||||||
|
.io_i16()
|
||||||
|
.map_err(|e| AppError::AudioError(format!("Failed to get PCM IO: {}", e)))?;
|
||||||
|
|
||||||
|
let mut buffer = vec![0i16; buf_frames * 2];
|
||||||
|
let mut next_log = Instant::now();
|
||||||
|
|
||||||
|
while !stop_flag.load(Ordering::SeqCst) {
|
||||||
|
match io.readi(&mut buffer[..period_frames * 2]) {
|
||||||
|
Ok(frames_read) => {
|
||||||
|
if frames_read == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let samples = frames_read * 2;
|
||||||
|
let data = Bytes::copy_from_slice(bytemuck::cast_slice(&buffer[..samples]));
|
||||||
|
let seq = sequence.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let frame = AudioFrame::new_interleaved(data, 2, 48_000, seq);
|
||||||
|
let _ = frame_tx.send(frame);
|
||||||
|
if next_log.elapsed().as_secs() >= 5 {
|
||||||
|
debug!("Captured audio frame {} ({} samples)", seq, samples / 2);
|
||||||
|
next_log = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn_throttled!(
|
||||||
|
log_throttler,
|
||||||
|
"alsa_read",
|
||||||
|
"ALSA read error on {}: {}",
|
||||||
|
config.device_name,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
let _ = pcm.try_recover(err, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = pcm.drain();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ use tokio::sync::RwLock;
|
|||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
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::encoder::OpusFrame;
|
||||||
use super::monitor::AudioHealthMonitor;
|
use super::monitor::AudioHealthMonitor;
|
||||||
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
|
||||||
use super::recovery;
|
use super::recovery;
|
||||||
|
use super::streamer::{AudioStreamer, AudioStreamerConfig};
|
||||||
use super::types::{AudioControllerConfig, AudioQuality, AudioStatus};
|
use super::types::{AudioControllerConfig, AudioQuality, AudioStatus};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::EventBus;
|
use crate::events::EventBus;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(feature = "android")))]
|
||||||
#[path = "device_linux.rs"]
|
#[path = "device_linux.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|
||||||
|
#[cfg(feature = "android")]
|
||||||
|
#[path = "device_android.rs"]
|
||||||
|
mod imp;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[path = "device_windows.rs"]
|
#[path = "device_windows.rs"]
|
||||||
mod imp;
|
mod imp;
|
||||||
|
|||||||
185
src/audio/device_android.rs
Normal file
185
src/audio/device_android.rs
Normal file
@@ -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<u32>,
|
||||||
|
pub channels: Vec<u32>,
|
||||||
|
pub is_capture: bool,
|
||||||
|
pub is_hdmi: bool,
|
||||||
|
pub usb_bus: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
||||||
|
if card_index < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_path = format!("/sys/class/sound/card{}/device", card_index);
|
||||||
|
let link_target = std::fs::read_link(&device_path).ok()?;
|
||||||
|
let link_str = link_target.to_string_lossy();
|
||||||
|
|
||||||
|
for component in link_str.split('/') {
|
||||||
|
if component.contains('-') && !component.contains(':') {
|
||||||
|
if component
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Some(component.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
enumerate_audio_devices_with_current(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enumerate_audio_devices_with_current(
|
||||||
|
current_device: Option<&str>,
|
||||||
|
) -> Result<Vec<AudioDeviceInfo>> {
|
||||||
|
let mut devices = Vec::new();
|
||||||
|
|
||||||
|
for card_result in alsa::card::Iter::new() {
|
||||||
|
let card = match card_result {
|
||||||
|
Ok(card) => card,
|
||||||
|
Err(err) => {
|
||||||
|
debug!("Error iterating card: {}", err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let card_index = card.get_index();
|
||||||
|
let card_name = card.get_name().unwrap_or_else(|_| "Unknown".to_string());
|
||||||
|
let card_longname = card.get_longname().unwrap_or_else(|_| card_name.clone());
|
||||||
|
|
||||||
|
debug!("Found audio card {}: {}", card_index, card_longname);
|
||||||
|
|
||||||
|
let long_lower = card_longname.to_lowercase();
|
||||||
|
let is_hdmi = long_lower.contains("hdmi")
|
||||||
|
|| long_lower.contains("capture")
|
||||||
|
|| long_lower.contains("usb");
|
||||||
|
let usb_bus = get_usb_bus_info(card_index);
|
||||||
|
|
||||||
|
for device_index in 0..8 {
|
||||||
|
let device_name = format!("hw:{},{}", card_index, device_index);
|
||||||
|
let is_current_device = current_device == Some(device_name.as_str());
|
||||||
|
|
||||||
|
let mut push_info =
|
||||||
|
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
|
||||||
|
devices.push(AudioDeviceInfo {
|
||||||
|
name: device_name.clone(),
|
||||||
|
description,
|
||||||
|
card_index,
|
||||||
|
device_index,
|
||||||
|
sample_rates,
|
||||||
|
channels,
|
||||||
|
is_capture: true,
|
||||||
|
is_hdmi,
|
||||||
|
usb_bus: usb_bus.clone(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
match PCM::new(&device_name, Direction::Capture, false) {
|
||||||
|
Ok(pcm) => {
|
||||||
|
let (sample_rates, channels) = query_device_caps(&pcm);
|
||||||
|
if !sample_rates.is_empty() && !channels.is_empty() {
|
||||||
|
push_info(
|
||||||
|
sample_rates,
|
||||||
|
channels,
|
||||||
|
format!("{} - Device {}", card_longname, device_index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) if is_current_device => {
|
||||||
|
debug!(
|
||||||
|
"Device {} is busy (in use by us), adding with default caps",
|
||||||
|
device_name
|
||||||
|
);
|
||||||
|
push_info(
|
||||||
|
vec![44_100, 48_000],
|
||||||
|
vec![2],
|
||||||
|
format!("{} - Device {} (in use)", card_longname, device_index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Found {} audio capture devices", devices.len());
|
||||||
|
Ok(devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
|
||||||
|
let hwp = match HwParams::any(pcm) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return (vec![], vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
|
||||||
|
let mut supported_rates = Vec::new();
|
||||||
|
|
||||||
|
for rate in &common_rates {
|
||||||
|
if hwp.test_rate(*rate).is_ok() {
|
||||||
|
supported_rates.push(*rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut supported_channels = Vec::new();
|
||||||
|
for ch in 1..=8 {
|
||||||
|
if hwp.test_channels(ch).is_ok() {
|
||||||
|
supported_channels.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(supported_rates, supported_channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||||
|
let devices = enumerate_audio_devices()?;
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
return Err(AppError::AudioError(
|
||||||
|
"No audio capture devices found".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
|
||||||
|
for device in &devices {
|
||||||
|
if !device.sample_rates.contains(&48_000) || !device.channels.contains(&2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if device.is_hdmi {
|
||||||
|
info!("Selected HDMI audio device: {}", device.description);
|
||||||
|
return Ok(device.clone());
|
||||||
|
}
|
||||||
|
if first_48k_stereo.is_none() {
|
||||||
|
first_48k_stereo = Some(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(device) = first_48k_stereo {
|
||||||
|
info!("Selected audio device: {}", device.description);
|
||||||
|
return Ok(device.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = devices.into_iter().next().unwrap();
|
||||||
|
warn!(
|
||||||
|
"Using fallback audio device: {} (may not support optimal settings)",
|
||||||
|
device.description
|
||||||
|
);
|
||||||
|
Ok(device)
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@ use tokio::sync::RwLock;
|
|||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::capture::AudioConfig;
|
use super::capture::AudioConfig;
|
||||||
|
use super::controller::AudioRecoveredCallback;
|
||||||
use super::device::{enumerate_audio_devices, AudioDeviceInfo};
|
use super::device::{enumerate_audio_devices, AudioDeviceInfo};
|
||||||
use super::monitor::AudioHealthMonitor;
|
use super::monitor::AudioHealthMonitor;
|
||||||
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
use super::streamer::{AudioStreamState, AudioStreamer, AudioStreamerConfig};
|
||||||
use super::types::AudioControllerConfig;
|
use super::types::AudioControllerConfig;
|
||||||
use super::controller::AudioRecoveredCallback;
|
|
||||||
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
use crate::events::{EventBus, StreamDeviceLostKind, SystemEvent};
|
||||||
|
|
||||||
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
const AUDIO_RECOVERY_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|||||||
@@ -25,4 +25,3 @@ impl AtxConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,4 +61,3 @@ impl std::fmt::Display for BitratePreset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,4 +306,3 @@ impl HidConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,3 @@ impl AppConfig {
|
|||||||
crate::platform::defaults::apply(self);
|
crate::platform::defaults::apply(self);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,4 +146,3 @@ impl Default for RedfishConfig {
|
|||||||
Self { enabled: false }
|
Self { enabled: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,19 @@ fn get_meminfo() -> MemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_network_addresses() -> Vec<NetworkAddress> {
|
fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||||
|
#[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<NetworkAddress> {
|
||||||
let all_addrs = match nix::ifaddrs::getifaddrs() {
|
let all_addrs = match nix::ifaddrs::getifaddrs() {
|
||||||
Ok(addrs) => addrs,
|
Ok(addrs) => addrs,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
@@ -247,6 +260,101 @@ fn get_network_addresses() -> Vec<NetworkAddress> {
|
|||||||
addresses
|
addresses
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn get_network_addresses_android() -> Vec<NetworkAddress> {
|
||||||
|
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut addresses = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for entry in net_dir.flatten() {
|
||||||
|
let iface_name = match entry.file_name().into_string() {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if iface_name == "lo" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let operstate_path = entry.path().join("operstate");
|
||||||
|
let is_up = std::fs::read_to_string(&operstate_path)
|
||||||
|
.map(|s| s.trim() == "up")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_up {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(ip) = android_ipv4_for_interface(&iface_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if ip.is_loopback() || ip.is_unspecified() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_str = ip.to_string();
|
||||||
|
if seen.insert((iface_name.clone(), ip_str.clone())) {
|
||||||
|
addresses.push(NetworkAddress {
|
||||||
|
interface: iface_name,
|
||||||
|
ip: ip_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn android_ipv4_for_interface(iface_name: &str) -> Option<std::net::Ipv4Addr> {
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::mem::{size_of, zeroed};
|
||||||
|
|
||||||
|
let name = CString::new(iface_name).ok()?;
|
||||||
|
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||||
|
if fd < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request: libc::ifreq = zeroed();
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
name.as_ptr(),
|
||||||
|
request.ifr_name.as_mut_ptr(),
|
||||||
|
name.as_bytes_with_nul().len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||||
|
let result = libc::ioctl(fd, request_code, &mut request);
|
||||||
|
libc::close(fd);
|
||||||
|
if result < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sockaddr = request.ifr_ifru.ifru_addr;
|
||||||
|
if sockaddr.sa_family as libc::c_int != libc::AF_INET {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut storage = [0u8; size_of::<libc::sockaddr_in>()];
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
&sockaddr as *const libc::sockaddr as *const u8,
|
||||||
|
storage.as_mut_ptr(),
|
||||||
|
size_of::<libc::sockaddr>(),
|
||||||
|
);
|
||||||
|
let sockaddr_in = &*(storage.as_ptr() as *const libc::sockaddr_in);
|
||||||
|
Some(std::net::Ipv4Addr::from(u32::from_be(
|
||||||
|
sockaddr_in.sin_addr.s_addr,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};
|
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const RECONNECT_DELAY_MS: u64 = 2000;
|
|||||||
|
|
||||||
const INIT_WAIT_MS: u64 = 3000;
|
const INIT_WAIT_MS: u64 = 3000;
|
||||||
|
|
||||||
|
|
||||||
struct Ch9329RuntimeState {
|
struct Ch9329RuntimeState {
|
||||||
initialized: AtomicBool,
|
initialized: AtomicBool,
|
||||||
online: AtomicBool,
|
online: AtomicBool,
|
||||||
@@ -843,8 +842,8 @@ impl HidBackend for Ch9329Backend {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use super::ch9329_proto::{build_packet, calculate_checksum};
|
use super::ch9329_proto::{build_packet, calculate_checksum};
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_packet_building() {
|
fn test_packet_building() {
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ pub fn calculate_checksum(data: &[u8]) -> u8 {
|
|||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn build_packet_buf(address: u8, cmd: u8, data: &[u8]) -> ([u8; MAX_PACKET_SIZE], usize) {
|
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 len = data.len() as u8;
|
||||||
let packet_len = 6 + data.len();
|
let packet_len = 6 + data.len();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
mod ch9329_proto;
|
|
||||||
pub mod ch9329;
|
pub mod ch9329;
|
||||||
|
mod ch9329_proto;
|
||||||
pub mod consumer;
|
pub mod consumer;
|
||||||
pub mod datachannel;
|
pub mod datachannel;
|
||||||
mod factory;
|
mod factory;
|
||||||
|
|||||||
@@ -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. <https://github.com/raspberrypi/linux/issues/4373>
|
//! 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. <https://github.com/raspberrypi/linux/issues/4373>
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
@@ -14,7 +15,6 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, info, trace, warn};
|
use tracing::{debug, info, trace, warn};
|
||||||
|
|
||||||
@@ -222,15 +222,7 @@ impl OtgBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_udc() -> Option<String> {
|
fn find_udc() -> Option<String> {
|
||||||
let udc_path = PathBuf::from("/sys/class/udc");
|
crate::otg::configfs::find_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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PiKVM-style: drop handle if node missing; reopen when path reappears.
|
/// PiKVM-style: drop handle if node missing; reopen when path reappears.
|
||||||
|
|||||||
33
src/lib.rs
33
src/lib.rs
@@ -1,37 +1,64 @@
|
|||||||
//! Core library for One-KVM (IP‑KVM: capture, HID, OTG, streaming, Web UI glue).
|
//! 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.");
|
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;
|
pub mod atx;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod extensions;
|
pub mod extensions;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod hid;
|
pub mod hid;
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||||
pub mod msd;
|
pub mod msd;
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||||
pub mod otg;
|
pub mod otg;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod redfish;
|
pub mod redfish;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod rtsp;
|
pub mod rtsp;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod rustdesk;
|
pub mod rustdesk;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod stream_encoder;
|
pub mod stream_encoder;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod video;
|
pub mod video;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod webrtc;
|
pub mod webrtc;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod secrets {
|
pub mod secrets {
|
||||||
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
|
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub use error::{AppError, Result};
|
pub use error::{AppError, Result};
|
||||||
|
|||||||
@@ -49,16 +49,26 @@ pub fn ensure_libcomposite_loaded() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_udc() -> Option<String> {
|
pub fn find_udc() -> Option<String> {
|
||||||
let udc_path = Path::new("/sys/class/udc");
|
list_udcs().into_iter().next()
|
||||||
if !udc_path.exists() {
|
}
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::read_dir(udc_path)
|
pub fn list_udcs() -> Vec<String> {
|
||||||
.ok()?
|
let mut devices = Vec::new();
|
||||||
.filter_map(|e| e.ok())
|
collect_dir_names(Path::new("/sys/class/udc"), &mut devices);
|
||||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
devices.sort();
|
||||||
.next()
|
devices.dedup();
|
||||||
|
devices
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
|
||||||
|
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 {
|
pub fn is_low_endpoint_udc(name: &str) -> bool {
|
||||||
|
|||||||
@@ -25,13 +25,12 @@ pub use service::{HidDevicePaths, OtgService};
|
|||||||
|
|
||||||
/// List USB Device Controller names exposed by sysfs.
|
/// List USB Device Controller names exposed by sysfs.
|
||||||
pub fn list_udc_devices() -> Vec<String> {
|
pub fn list_udc_devices() -> Vec<String> {
|
||||||
let mut devices: Vec<String> = std::fs::read_dir("/sys/class/udc")
|
#[cfg(unix)]
|
||||||
.ok()
|
{
|
||||||
.into_iter()
|
configfs::list_udcs()
|
||||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
}
|
||||||
.filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
|
#[cfg(not(unix))]
|
||||||
.collect();
|
{
|
||||||
|
Vec::new()
|
||||||
devices.sort();
|
}
|
||||||
devices
|
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/platform/android.rs
Normal file
43
src/platform/android.rs
Normal file
@@ -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"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/platform/android_bionic.rs
Normal file
175
src/platform/android_bionic.rs
Normal file
@@ -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<std::net::Ipv4Addr> {
|
||||||
|
if addr.sa_family as c_int != libc::AF_INET {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let sin = &*(&addr as *const libc::sockaddr as *const libc::sockaddr_in);
|
||||||
|
Some(std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_ipv4(iface_name: &str) -> Option<libc::sockaddr_in> {
|
||||||
|
let name = CString::new(iface_name).ok()?;
|
||||||
|
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||||
|
if fd < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request: libc::ifreq = zeroed();
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
name.as_ptr(),
|
||||||
|
request.ifr_name.as_mut_ptr(),
|
||||||
|
name.as_bytes_with_nul().len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||||
|
let rc = libc::ioctl(fd, request_code, &mut request);
|
||||||
|
libc::close(fd);
|
||||||
|
if rc < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr = request.ifr_ifru.ifru_addr;
|
||||||
|
if addr.sa_family as c_int != libc::AF_INET {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sin: libc::sockaddr_in = zeroed();
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
&addr as *const libc::sockaddr as *const u8,
|
||||||
|
&mut sin as *mut libc::sockaddr_in as *mut u8,
|
||||||
|
size_of::<libc::sockaddr_in>(),
|
||||||
|
);
|
||||||
|
Some(sin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn getifaddrs(addrs: *mut *mut ifaddrs) -> c_int {
|
||||||
|
if addrs.is_null() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*addrs = std::ptr::null_mut();
|
||||||
|
|
||||||
|
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(_) => return -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut head: *mut AddrNode = std::ptr::null_mut();
|
||||||
|
let mut tail: *mut AddrNode = std::ptr::null_mut();
|
||||||
|
|
||||||
|
for entry in net_dir.flatten() {
|
||||||
|
let iface_name = match entry.file_name().into_string() {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if iface_name == "lo" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let operstate_path = entry.path().join("operstate");
|
||||||
|
let is_up = std::fs::read_to_string(&operstate_path)
|
||||||
|
.map(|s| s.trim() == "up")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_up {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(addr) = query_ipv4(&iface_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ip = sockaddr_to_ipv4(unsafe {
|
||||||
|
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr)
|
||||||
|
});
|
||||||
|
if ip
|
||||||
|
.map(|ip| ip.is_loopback() || ip.is_unspecified())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = match CString::new(iface_name) {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut node = Box::new(AddrNode {
|
||||||
|
ifa: ifaddrs {
|
||||||
|
ifa_next: std::ptr::null_mut(),
|
||||||
|
ifa_name: std::ptr::null_mut(),
|
||||||
|
ifa_flags: 0,
|
||||||
|
ifa_addr: std::ptr::null_mut(),
|
||||||
|
ifa_netmask: std::ptr::null_mut(),
|
||||||
|
ifa_ifu: std::ptr::null_mut(),
|
||||||
|
ifa_data: std::ptr::null_mut(),
|
||||||
|
},
|
||||||
|
name,
|
||||||
|
addr,
|
||||||
|
next: std::ptr::null_mut(),
|
||||||
|
});
|
||||||
|
|
||||||
|
node.ifa.ifa_name = node.name.as_ptr() as *mut c_char;
|
||||||
|
node.ifa.ifa_addr = &mut node.addr as *mut libc::sockaddr_in as *mut libc::sockaddr;
|
||||||
|
node.ifa.ifa_ifu = std::ptr::null_mut();
|
||||||
|
node.ifa.ifa_netmask = std::ptr::null_mut();
|
||||||
|
node.ifa.ifa_flags = (libc::IFF_UP | libc::IFF_RUNNING) as c_uint;
|
||||||
|
|
||||||
|
let raw = Box::into_raw(node);
|
||||||
|
if head.is_null() {
|
||||||
|
head = raw;
|
||||||
|
} else {
|
||||||
|
(*tail).next = raw;
|
||||||
|
(*tail).ifa.ifa_next = raw as *mut ifaddrs;
|
||||||
|
}
|
||||||
|
tail = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
*addrs = if head.is_null() {
|
||||||
|
std::ptr::null_mut()
|
||||||
|
} else {
|
||||||
|
head as *mut ifaddrs
|
||||||
|
};
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn freeifaddrs(addrs: *mut ifaddrs) {
|
||||||
|
let mut current = addrs as *mut AddrNode;
|
||||||
|
while !current.is_null() {
|
||||||
|
let next = (*current).next;
|
||||||
|
drop(Box::from_raw(current));
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PlatformMode {
|
pub enum PlatformMode {
|
||||||
|
AndroidAmlogic,
|
||||||
Linux,
|
Linux,
|
||||||
Windows,
|
Windows,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformMode {
|
impl PlatformMode {
|
||||||
pub const fn current() -> Self {
|
pub const fn current() -> Self {
|
||||||
if cfg!(windows) {
|
if cfg!(feature = "android") {
|
||||||
|
Self::AndroidAmlogic
|
||||||
|
} else if cfg!(windows) {
|
||||||
Self::Windows
|
Self::Windows
|
||||||
} else {
|
} else {
|
||||||
Self::Linux
|
Self::Linux
|
||||||
@@ -20,6 +23,7 @@ impl PlatformMode {
|
|||||||
|
|
||||||
pub const fn label(self) -> &'static str {
|
pub const fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Self::AndroidAmlogic => "Android Amlogic",
|
||||||
Self::Linux => "Linux",
|
Self::Linux => "Linux",
|
||||||
Self::Windows => "Windows",
|
Self::Windows => "Windows",
|
||||||
}
|
}
|
||||||
@@ -81,9 +85,17 @@ pub struct PlatformCapabilities {
|
|||||||
|
|
||||||
impl PlatformCapabilities {
|
impl PlatformCapabilities {
|
||||||
pub fn current() -> Self {
|
pub fn current() -> Self {
|
||||||
match PlatformMode::current() {
|
#[cfg(feature = "android")]
|
||||||
PlatformMode::Linux => crate::platform::linux::capabilities(),
|
{
|
||||||
PlatformMode::Windows => crate::platform::windows::capabilities(),
|
return crate::platform::android::capabilities();
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
return crate::platform::windows::capabilities();
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(feature = "android")))]
|
||||||
|
{
|
||||||
|
return crate::platform::linux::capabilities();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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);
|
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) {
|
fn apply_windows(config: &mut AppConfig) {
|
||||||
config.msd.enabled = false;
|
config.msd.enabled = false;
|
||||||
config.hid.otg_udc = None;
|
config.hid.otg_udc = None;
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
//! Platform selection and capability reporting.
|
//! Platform selection and capability reporting.
|
||||||
|
|
||||||
|
#[cfg(feature = "android")]
|
||||||
|
pub mod android;
|
||||||
|
#[cfg(feature = "android")]
|
||||||
|
pub mod android_bionic;
|
||||||
pub mod capabilities;
|
pub mod capabilities;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux;
|
pub mod linux;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub mod usb_reset;
|
pub mod usb_reset;
|
||||||
|
#[cfg(windows)]
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|
||||||
pub use capabilities::{FeatureCapability, PlatformCapabilities, PlatformMode};
|
pub use capabilities::{FeatureCapability, PlatformCapabilities, PlatformMode};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ mod event;
|
|||||||
mod managers;
|
mod managers;
|
||||||
mod session;
|
mod session;
|
||||||
mod systems;
|
mod systems;
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(feature = "android")))]
|
||||||
mod virtual_media;
|
mod virtual_media;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -200,7 +200,7 @@ pub fn create_redfish_router(state: Arc<AppState>) -> Router {
|
|||||||
redfish_auth_middleware,
|
redfish_auth_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(feature = "android")))]
|
||||||
let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone()));
|
let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone()));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
735
src/runtime/android.rs
Normal file
735
src/runtime/android.rs
Normal file
@@ -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<Mutex<Option<RuntimeHandle>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn handle_slot() -> &'static Mutex<Option<RuntimeHandle>> {
|
||||||
|
HANDLE.get_or_init(|| Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(config: AndroidRuntimeConfig) -> Result<String, String> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut slot = handle_slot()
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "runtime lock poisoned".to_string())?;
|
||||||
|
if slot.is_some() {
|
||||||
|
return Ok(status());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (stop_tx, stop_rx) = oneshot::channel();
|
||||||
|
let config_for_thread = config.clone();
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("one-kvm-android-runtime".to_string())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(err) = run_runtime(config_for_thread, stop_rx) {
|
||||||
|
tracing::error!("One-KVM Android runtime exited: {}", err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|err| format!("failed to spawn runtime: {err}"))?;
|
||||||
|
|
||||||
|
*slot = Some(RuntimeHandle { stop_tx, join });
|
||||||
|
Ok(format!(
|
||||||
|
"One-KVM Android runtime starting on http://{}:{}",
|
||||||
|
config.bind_address, config.port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_foreground(config: AndroidRuntimeConfig) -> Result<(), String> {
|
||||||
|
init_logging();
|
||||||
|
let (_stop_tx, stop_rx) = oneshot::channel();
|
||||||
|
run_runtime(config, stop_rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_rustls_provider() {
|
||||||
|
ensure_rustls_provider();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop() -> String {
|
||||||
|
let handle = match handle_slot().lock() {
|
||||||
|
Ok(mut slot) => slot.take(),
|
||||||
|
Err(_) => return "runtime lock poisoned".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(handle) = handle else {
|
||||||
|
return "One-KVM Android runtime is not running".to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = handle.stop_tx.send(());
|
||||||
|
match handle.join.join() {
|
||||||
|
Ok(()) => "One-KVM Android runtime stopped".to_string(),
|
||||||
|
Err(_) => "One-KVM Android runtime stopped after panic".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status() -> String {
|
||||||
|
match handle_slot().lock() {
|
||||||
|
Ok(slot) if slot.is_some() => "One-KVM Android runtime running".to_string(),
|
||||||
|
Ok(_) => "One-KVM Android runtime stopped".to_string(),
|
||||||
|
Err(_) => "runtime lock poisoned".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_runtime(config: AndroidRuntimeConfig, stop_rx: oneshot::Receiver<()>) -> Result<(), String> {
|
||||||
|
ensure_rustls_provider();
|
||||||
|
let runtime = Runtime::new().map_err(|err| format!("failed to create tokio runtime: {err}"))?;
|
||||||
|
runtime.block_on(async move { run_async(config, stop_rx).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_async(
|
||||||
|
config: AndroidRuntimeConfig,
|
||||||
|
stop_rx: oneshot::Receiver<()>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (db, config_store, app_config) =
|
||||||
|
load_runtime_config(&PathBuf::from(&config.data_dir), &config).await?;
|
||||||
|
let (shutdown_tx, _) = broadcast::channel::<()>(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<Arc<AppState>, String> {
|
||||||
|
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||||
|
let user_store = UserStore::new(db.clone_pool());
|
||||||
|
let events = Arc::new(EventBus::new());
|
||||||
|
|
||||||
|
let (video_format, video_resolution) = parse_video_config(&config);
|
||||||
|
let streamer = Streamer::new();
|
||||||
|
streamer.set_event_bus(events.clone()).await;
|
||||||
|
if let Some(ref device_path) = config.video.device {
|
||||||
|
if let Err(err) = streamer
|
||||||
|
.apply_video_config(
|
||||||
|
device_path,
|
||||||
|
video_format,
|
||||||
|
video_resolution,
|
||||||
|
config.video.fps,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Android video config failed, falling back to auto: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let webrtc_streamer = WebRtcStreamer::with_config(WebRtcStreamerConfig {
|
||||||
|
resolution: video_resolution,
|
||||||
|
input_format: video_format,
|
||||||
|
fps: config.video.fps,
|
||||||
|
bitrate_preset: config.stream.bitrate_preset,
|
||||||
|
encoder_backend: encoder_type_to_backend(config.stream.encoder.clone()),
|
||||||
|
webrtc: build_webrtc_config(&config),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let hid_backend = match config.hid.backend {
|
||||||
|
config::HidBackend::Otg => HidBackendType::Otg,
|
||||||
|
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||||
|
port: config.hid.ch9329_port.clone(),
|
||||||
|
baud_rate: config.hid.ch9329_baudrate,
|
||||||
|
},
|
||||||
|
config::HidBackend::None => HidBackendType::None,
|
||||||
|
};
|
||||||
|
let otg_service = Arc::new(OtgService::new());
|
||||||
|
if let Err(err) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||||
|
tracing::warn!("Failed to apply Android OTG config: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
|
||||||
|
hid.set_event_bus(events.clone()).await;
|
||||||
|
if let Err(err) = hid.init().await {
|
||||||
|
tracing::warn!("Failed to initialize Android HID backend: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let msd = if config.msd.enabled {
|
||||||
|
let ventoy_resource_dir = data_dir.join("ventoy");
|
||||||
|
if ventoy_resource_dir.exists() {
|
||||||
|
if let Err(err) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
||||||
|
tracing::warn!("Failed to initialize Android Ventoy resources: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
|
||||||
|
if let Err(err) = controller.init().await {
|
||||||
|
tracing::warn!("Failed to initialize Android MSD controller: {}", err);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
controller.set_event_bus(events.clone()).await;
|
||||||
|
Some(controller)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let atx = if config.atx.enabled {
|
||||||
|
let controller = AtxController::new(config.atx.to_controller_config());
|
||||||
|
if let Err(err) = controller.init().await {
|
||||||
|
tracing::warn!("Failed to initialize Android ATX controller: {}", err);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(controller)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let audio = {
|
||||||
|
let audio_config = AudioControllerConfig {
|
||||||
|
enabled: config.audio.enabled,
|
||||||
|
device: config.audio.device.clone(),
|
||||||
|
quality: config
|
||||||
|
.audio
|
||||||
|
.quality
|
||||||
|
.parse::<AudioQuality>()
|
||||||
|
.unwrap_or(AudioQuality::Balanced),
|
||||||
|
};
|
||||||
|
let controller = AudioController::new(audio_config);
|
||||||
|
controller.set_event_bus(events.clone()).await;
|
||||||
|
if config.audio.enabled {
|
||||||
|
if let Err(err) = controller.start_streaming().await {
|
||||||
|
tracing::warn!("Failed to start Android audio: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Arc::new(controller)
|
||||||
|
};
|
||||||
|
|
||||||
|
let extensions = Arc::new(ExtensionManager::new());
|
||||||
|
webrtc_streamer.set_hid_controller(hid.clone()).await;
|
||||||
|
webrtc_streamer.set_audio_controller(audio.clone()).await;
|
||||||
|
|
||||||
|
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||||
|
streamer.current_capture_config().await;
|
||||||
|
webrtc_streamer
|
||||||
|
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||||
|
.await;
|
||||||
|
if let Some(device_path) = device_path {
|
||||||
|
let (subdev_path, bridge_kind, v4l2_driver) = streamer
|
||||||
|
.current_device()
|
||||||
|
.await
|
||||||
|
.map(|device| {
|
||||||
|
(
|
||||||
|
device.subdev_path.clone(),
|
||||||
|
device.bridge_kind.clone(),
|
||||||
|
Some(device.driver.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((None, None, None));
|
||||||
|
webrtc_streamer
|
||||||
|
.set_capture_device(
|
||||||
|
device_path,
|
||||||
|
jpeg_quality,
|
||||||
|
subdev_path,
|
||||||
|
bridge_kind,
|
||||||
|
v4l2_driver,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_manager = VideoStreamManager::with_webrtc_streamer(
|
||||||
|
streamer.clone(),
|
||||||
|
webrtc_streamer.clone() as Arc<dyn crate::video::traits::VideoOutput>,
|
||||||
|
);
|
||||||
|
stream_manager.set_event_bus(events.clone()).await;
|
||||||
|
stream_manager.set_config_store(config_store.clone()).await;
|
||||||
|
{
|
||||||
|
let stream_manager_weak = Arc::downgrade(&stream_manager);
|
||||||
|
audio
|
||||||
|
.set_recovered_callback(Arc::new(move || {
|
||||||
|
if let Some(stream_manager) = stream_manager_weak.upgrade() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
stream_manager.reconnect_webrtc_audio_sources().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = stream_manager
|
||||||
|
.init_with_mode(config.stream.mode.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Failed to initialize Android stream manager: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
let 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::<PixelFormat>().ok())
|
||||||
|
.unwrap_or(PixelFormat::Mjpeg);
|
||||||
|
(
|
||||||
|
format,
|
||||||
|
Resolution::new(config.video.width, config.video.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_android_listener(bind_address: &str, port: u16) -> Result<std::net::TcpListener, String> {
|
||||||
|
let ip = bind_address
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.map_err(|err| format!("invalid Android bind address {bind_address}: {err}"))?;
|
||||||
|
bind_tcp_listener(SocketAddr::new(ip, port))
|
||||||
|
.map_err(|err| format!("failed to bind Android listener {bind_address}:{port}: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||||
|
enum DeviceInfoTrigger {
|
||||||
|
Event,
|
||||||
|
Lagged { topic: &'static str, count: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||||
|
"stream.state_changed",
|
||||||
|
"stream.config_applied",
|
||||||
|
"stream.mode_ready",
|
||||||
|
];
|
||||||
|
const DEBOUNCE_MS: u64 = 100;
|
||||||
|
|
||||||
|
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||||
|
for topic in DEVICE_INFO_TOPICS {
|
||||||
|
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
let topic_name = *topic;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(_) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: topic_name,
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||||
|
let trigger_tx = trigger_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match dirty_rx.recv().await {
|
||||||
|
Ok(()) => {
|
||||||
|
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
if trigger_tx
|
||||||
|
.send(DeviceInfoTrigger::Lagged {
|
||||||
|
topic: "device_info_dirty",
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||||
|
let mut pending_broadcast = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let recv_result = if pending_broadcast {
|
||||||
|
let remaining =
|
||||||
|
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||||
|
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||||
|
} else {
|
||||||
|
Ok(trigger_rx.recv().await)
|
||||||
|
};
|
||||||
|
|
||||||
|
match recv_result {
|
||||||
|
Ok(Some(DeviceInfoTrigger::Event)) => pending_broadcast = true,
|
||||||
|
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Android device info broadcaster lagged by {} events on {}",
|
||||||
|
count,
|
||||||
|
topic
|
||||||
|
);
|
||||||
|
pending_broadcast = true;
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
|
||||||
|
state.publish_device_info().await;
|
||||||
|
last_broadcast = Instant::now();
|
||||||
|
pending_broadcast = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup(state: &Arc<AppState>) {
|
||||||
|
state.extensions.stop_all().await;
|
||||||
|
|
||||||
|
if let Some(service) = state.rustdesk.read().await.as_ref() {
|
||||||
|
if let Err(err) = service.stop().await {
|
||||||
|
tracing::warn!("Failed to stop Android RustDesk service: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(service) = state.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());
|
||||||
|
});
|
||||||
|
}
|
||||||
4
src/runtime/mod.rs
Normal file
4
src/runtime/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
//! Runtime entry points for packaged service modes.
|
||||||
|
|
||||||
|
#[cfg(feature = "android")]
|
||||||
|
pub mod android;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
use parking_lot::Mutex as ParkingMutex;
|
use parking_lot::Mutex as ParkingMutex;
|
||||||
use parking_lot::RwLock as ParkingRwLock;
|
use parking_lot::RwLock as ParkingRwLock;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
@@ -6,13 +7,18 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::broadcast;
|
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.
|
/// Generation token paired with `client_id` so [`unregister_client`] ignores stale drops.
|
||||||
pub type ClientGeneration = u64;
|
pub type ClientGeneration = u64;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
use crate::video::codec::traits::{Encoder, EncoderConfig};
|
use crate::video::codec::traits::{Encoder, EncoderConfig};
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
use crate::video::codec::JpegEncoder;
|
use crate::video::codec::JpegEncoder;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
use crate::video::format::PixelFormat;
|
use crate::video::format::PixelFormat;
|
||||||
use crate::video::VideoFrame;
|
use crate::video::VideoFrame;
|
||||||
|
|
||||||
@@ -108,6 +114,7 @@ pub struct MjpegStreamHandler {
|
|||||||
last_frame_ts: ParkingRwLock<Option<Instant>>,
|
last_frame_ts: ParkingRwLock<Option<Instant>>,
|
||||||
dropped_same_frames: AtomicU64,
|
dropped_same_frames: AtomicU64,
|
||||||
max_drop_same_frames: AtomicU64,
|
max_drop_same_frames: AtomicU64,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>,
|
jpeg_encoder: ParkingMutex<Option<JpegEncoder>>,
|
||||||
jpeg_quality: AtomicU64,
|
jpeg_quality: AtomicU64,
|
||||||
}
|
}
|
||||||
@@ -126,6 +133,7 @@ impl MjpegStreamHandler {
|
|||||||
sequence: AtomicU64::new(0),
|
sequence: AtomicU64::new(0),
|
||||||
clients: ParkingRwLock::new(HashMap::new()),
|
clients: ParkingRwLock::new(HashMap::new()),
|
||||||
next_generation: AtomicU64::new(1),
|
next_generation: AtomicU64::new(1),
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
jpeg_encoder: ParkingMutex::new(None),
|
jpeg_encoder: ParkingMutex::new(None),
|
||||||
auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()),
|
auto_pause_config: ParkingRwLock::new(AutoPauseConfig::default()),
|
||||||
last_frame_ts: ParkingRwLock::new(None),
|
last_frame_ts: ParkingRwLock::new(None),
|
||||||
@@ -157,6 +165,7 @@ impl MjpegStreamHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frame = if !frame.format.is_compressed() {
|
let frame = if !frame.format.is_compressed() {
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
match self.encode_to_jpeg(&frame) {
|
match self.encode_to_jpeg(&frame) {
|
||||||
Ok(jpeg_frame) => jpeg_frame,
|
Ok(jpeg_frame) => jpeg_frame,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -164,6 +173,13 @@ impl MjpegStreamHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Dropping non-JPEG frame for MJPEG stream on Android; native encoder is not wired yet"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
frame
|
frame
|
||||||
};
|
};
|
||||||
@@ -200,6 +216,7 @@ impl MjpegStreamHandler {
|
|||||||
let _ = self.frame_notify.send(());
|
let _ = self.frame_notify.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
fn encode_to_jpeg(&self, frame: &VideoFrame) -> Result<VideoFrame, String> {
|
fn encode_to_jpeg(&self, frame: &VideoFrame) -> Result<VideoFrame, String> {
|
||||||
let resolution = frame.resolution;
|
let resolution = frame.resolution;
|
||||||
let sequence = self.sequence.load(Ordering::Relaxed);
|
let sequence = self.sequence.load(Ordering::Relaxed);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
|
//! MJPEG multipart streaming and WebSocket HID (for MJPEG mode).
|
||||||
|
|
||||||
pub mod mjpeg;
|
pub mod mjpeg;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
pub mod ws_hid;
|
pub mod ws_hid;
|
||||||
|
|
||||||
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
pub use mjpeg::{ClientGuard, MjpegStreamHandler};
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
pub use ws_hid::WsHidHandler;
|
pub use ws_hid::WsHidHandler;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
#[cfg(unix)]
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
pub mod net;
|
pub mod net;
|
||||||
#[cfg(not(unix))]
|
#[cfg(any(not(unix), target_os = "android"))]
|
||||||
#[path = "net_disabled.rs"]
|
#[path = "net_disabled.rs"]
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod serial;
|
pub mod serial;
|
||||||
|
|||||||
122
src/video/codec/android_mediacodec.rs
Normal file
122
src/video/codec/android_mediacodec.rs
Normal file
@@ -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<Self> {
|
||||||
|
let pixfmt = match input_format {
|
||||||
|
PixelFormat::Nv12 => resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
|
PixelFormat::Yuv420 => {
|
||||||
|
resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P)
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"FFmpeg h264_mediacodec accepts NV12/YUV420P memory frames; {other} requires conversion first"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx = EncodeContext {
|
||||||
|
name: "h264_mediacodec".to_string(),
|
||||||
|
mc_name: None,
|
||||||
|
width: resolution.width as i32,
|
||||||
|
height: resolution.height as i32,
|
||||||
|
pixfmt,
|
||||||
|
align: 1,
|
||||||
|
fps: fps.max(1) as i32,
|
||||||
|
gop: fps.max(1) as i32,
|
||||||
|
rc: RateControl::RC_CBR,
|
||||||
|
quality: Quality::Quality_Low,
|
||||||
|
kbs: bitrate_kbps.max(1) as i32,
|
||||||
|
q: 23,
|
||||||
|
thread_count: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let inner = HwEncoder::new(ctx).map_err(|_| {
|
||||||
|
AppError::VideoError("Failed to create FFmpeg h264_mediacodec encoder".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner,
|
||||||
|
resolution,
|
||||||
|
input_format,
|
||||||
|
bitrate_kbps: bitrate_kbps.max(1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<AndroidH264Packet>> {
|
||||||
|
let min_len = self
|
||||||
|
.input_format
|
||||||
|
.frame_size(self.resolution)
|
||||||
|
.ok_or_else(|| AppError::VideoError("MediaCodec input must be raw YUV".to_string()))?;
|
||||||
|
if data.len() < min_len {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"MediaCodec {} frame too small: {} < {}",
|
||||||
|
self.input_format,
|
||||||
|
data.len(),
|
||||||
|
min_len
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let packets = self
|
||||||
|
.inner
|
||||||
|
.encode_bytes(data, pts_ms)
|
||||||
|
.map_err(|err| AppError::VideoError(format!("h264_mediacodec encode failed: {err}")))?;
|
||||||
|
|
||||||
|
Ok(packets
|
||||||
|
.into_iter()
|
||||||
|
.map(|packet| AndroidH264Packet {
|
||||||
|
data: packet.data,
|
||||||
|
pts: packet.pts,
|
||||||
|
key_frame: packet.key == 1,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
|
self.inner
|
||||||
|
.set_bitrate(bitrate_kbps.max(1) as i32)
|
||||||
|
.map_err(|_| AppError::VideoError("Failed to set MediaCodec bitrate".to_string()))?;
|
||||||
|
self.bitrate_kbps = bitrate_kbps.max(1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_keyframe(&mut self) {
|
||||||
|
self.inner.request_keyframe();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn codec_name(&self) -> &str {
|
||||||
|
"h264_mediacodec"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_format(&self) -> PixelFormat {
|
||||||
|
self.input_format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for AndroidMediaCodecH264Encoder {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AndroidH264Packet {
|
||||||
|
pub data: Bytes,
|
||||||
|
pub pts: i64,
|
||||||
|
pub key_frame: bool,
|
||||||
|
}
|
||||||
137
src/video/codec/android_mjpeg.rs
Normal file
137
src/video/codec/android_mjpeg.rs
Normal file
@@ -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<Nv12Converter>,
|
||||||
|
last_output_format: Option<PixelFormat>,
|
||||||
|
pending_frames: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidMediaCodecMjpegDecoder {
|
||||||
|
pub fn new(resolution: Resolution) -> Result<Self> {
|
||||||
|
let ctx = DecodeContext {
|
||||||
|
name: "mjpeg_mediacodec".to_string(),
|
||||||
|
width: resolution.width as i32,
|
||||||
|
height: resolution.height as i32,
|
||||||
|
sw_pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||||
|
thread_count: 1,
|
||||||
|
};
|
||||||
|
let decoder = Decoder::new(ctx).map_err(|_| {
|
||||||
|
AppError::VideoError("Failed to create FFmpeg mjpeg_mediacodec decoder".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
decoder,
|
||||||
|
resolution,
|
||||||
|
nv12_converter: None,
|
||||||
|
last_output_format: None,
|
||||||
|
pending_frames: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_to_nv12(&mut self, mjpeg: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let frames = match self.decoder.decode(mjpeg) {
|
||||||
|
Ok(frames) => frames,
|
||||||
|
Err(err) if err == -11 => {
|
||||||
|
self.pending_frames += 1;
|
||||||
|
if self.pending_frames <= 3 {
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"mjpeg_mediacodec decode failed: {err}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if frames.is_empty() {
|
||||||
|
self.pending_frames += 1;
|
||||||
|
if self.pending_frames <= 3 {
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.pending_frames = 0;
|
||||||
|
if frames.len() > 1 {
|
||||||
|
warn!(
|
||||||
|
"mjpeg_mediacodec decode returned {} frames, using last",
|
||||||
|
frames.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = frames.pop().ok_or_else(|| {
|
||||||
|
AppError::VideoError("mjpeg_mediacodec decode returned empty".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if frame.width as u32 != self.resolution.width
|
||||||
|
|| frame.height as u32 != self.resolution.height
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"mjpeg_mediacodec output size {}x{} differs from expected {}x{}",
|
||||||
|
frame.width, frame.height, self.resolution.width, self.resolution.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_format = pixel_format_from_av(frame.pixfmt).ok_or_else(|| {
|
||||||
|
AppError::VideoError(format!(
|
||||||
|
"mjpeg_mediacodec output pixfmt {:?} is not supported",
|
||||||
|
frame.pixfmt
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if self.last_output_format != Some(output_format) {
|
||||||
|
info!("mjpeg_mediacodec output format: {}", output_format);
|
||||||
|
self.last_output_format = Some(output_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
match output_format {
|
||||||
|
PixelFormat::Nv12 => Ok(frame.data),
|
||||||
|
PixelFormat::Nv21 => {
|
||||||
|
let converter = self
|
||||||
|
.nv12_converter
|
||||||
|
.get_or_insert_with(|| Nv12Converter::nv21_to_nv12(self.resolution));
|
||||||
|
Ok(converter.convert(&frame.data)?.to_vec())
|
||||||
|
}
|
||||||
|
PixelFormat::Yuv420 => {
|
||||||
|
let converter = self
|
||||||
|
.nv12_converter
|
||||||
|
.get_or_insert_with(|| Nv12Converter::yuv420_to_nv12(self.resolution));
|
||||||
|
Ok(converter.convert(&frame.data)?.to_vec())
|
||||||
|
}
|
||||||
|
other => Err(AppError::VideoError(format!(
|
||||||
|
"mjpeg_mediacodec output {} cannot be converted to NV12",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixel_format_from_av(format: AVPixelFormat) -> Option<PixelFormat> {
|
||||||
|
match format {
|
||||||
|
AVPixelFormat::AV_PIX_FMT_NV12 => Some(PixelFormat::Nv12),
|
||||||
|
AVPixelFormat::AV_PIX_FMT_NV21 => Some(PixelFormat::Nv21),
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P | AVPixelFormat::AV_PIX_FMT_YUVJ420P => {
|
||||||
|
Some(PixelFormat::Yuv420)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for AndroidMediaCodecMjpegDecoder {}
|
||||||
@@ -539,8 +539,46 @@ pub struct Nv12Converter {
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
/// Output buffer (reused across conversions)
|
/// Output buffer (reused across conversions)
|
||||||
output_buffer: Nv12Buffer,
|
output_buffer: Nv12Buffer,
|
||||||
/// Optional I420 buffer for intermediate conversions
|
}
|
||||||
i420_buffer: Option<Yuv420pBuffer>,
|
|
||||||
|
/// 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 {
|
impl Nv12Converter {
|
||||||
@@ -550,7 +588,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Bgr24,
|
src_format: PixelFormat::Bgr24,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +597,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Rgb24,
|
src_format: PixelFormat::Rgb24,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +606,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Yuyv,
|
src_format: PixelFormat::Yuyv,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +615,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Yuv420,
|
src_format: PixelFormat::Yuv420,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +624,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Nv21,
|
src_format: PixelFormat::Nv21,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: Some(Yuv420pBuffer::new(resolution)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +633,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Nv16,
|
src_format: PixelFormat::Nv16,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(resolution),
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
i420_buffer: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +642,6 @@ impl Nv12Converter {
|
|||||||
src_format: PixelFormat::Nv24,
|
src_format: PixelFormat::Nv24,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Nv12Buffer::new(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
|
// Handle formats that need custom conversion without holding dst borrow
|
||||||
match self.src_format {
|
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 => {
|
PixelFormat::Nv16 => {
|
||||||
let dst = self.output_buffer.as_bytes_mut();
|
let dst = self.output_buffer.as_bytes_mut();
|
||||||
Self::convert_nv16_to_nv12_with_dims(
|
Self::convert_nv16_to_nv12_with_dims(
|
||||||
@@ -667,6 +681,7 @@ impl Nv12Converter {
|
|||||||
PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height),
|
PixelFormat::Rgb24 => libyuv::rgb24_to_nv12(input, dst, width, height),
|
||||||
PixelFormat::Yuyv => libyuv::yuy2_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::Yuv420 => libyuv::i420_to_nv12(input, dst, width, height),
|
||||||
|
PixelFormat::Nv21 => libyuv::nv21_to_nv12(input, dst, width, height),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::VideoError(format!(
|
return Err(AppError::VideoError(format!(
|
||||||
"Unsupported conversion to NV12: {}",
|
"Unsupported conversion to NV12: {}",
|
||||||
@@ -680,21 +695,6 @@ impl Nv12Converter {
|
|||||||
Ok(self.output_buffer.as_bytes())
|
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(
|
fn convert_nv16_to_nv12_with_dims(
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub enum H264EncoderType {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
|
/// Android MediaCodec via FFmpeg
|
||||||
|
MediaCodec,
|
||||||
/// Software encoding (libx264/openh264)
|
/// Software encoding (libx264/openh264)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -64,6 +66,7 @@ impl std::fmt::Display for H264EncoderType {
|
|||||||
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
|
H264EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||||
H264EncoderType::Software => write!(f, "Software"),
|
H264EncoderType::Software => write!(f, "Software"),
|
||||||
H264EncoderType::None => write!(f, "None"),
|
H264EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -80,6 +83,7 @@ impl From<EncoderBackend> for H264EncoderType {
|
|||||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||||
|
EncoderBackend::MediaCodec => H264EncoderType::MediaCodec,
|
||||||
EncoderBackend::Software => H264EncoderType::Software,
|
EncoderBackend::Software => H264EncoderType::Software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HwEncodeFrame {
|
pub struct HwEncodeFrame {
|
||||||
pub data: Vec<u8>,
|
pub data: Bytes,
|
||||||
pub pts: i64,
|
pub pts: i64,
|
||||||
pub key: i32,
|
pub key: i32,
|
||||||
}
|
}
|
||||||
@@ -372,14 +376,12 @@ impl H264Encoder {
|
|||||||
|
|
||||||
self.frame_count += 1;
|
self.frame_count += 1;
|
||||||
|
|
||||||
match self.inner.encode(data, pts_ms) {
|
match self.inner.encode_bytes(data, pts_ms) {
|
||||||
Ok(frames) => {
|
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<HwEncodeFrame> = frames
|
let owned_frames: Vec<HwEncodeFrame> = frames
|
||||||
.drain(..)
|
.into_iter()
|
||||||
.map(|f| HwEncodeFrame {
|
.map(|f| HwEncodeFrame {
|
||||||
data: f.data, // Move, not clone
|
data: f.data,
|
||||||
pts: f.pts,
|
pts: f.pts,
|
||||||
key: f.key,
|
key: f.key,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ pub enum H265EncoderType {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 M2M (ARM generic)
|
/// V4L2 M2M (ARM generic)
|
||||||
V4l2M2m,
|
V4l2M2m,
|
||||||
|
/// Android MediaCodec via FFmpeg
|
||||||
|
MediaCodec,
|
||||||
/// Software encoder (libx265)
|
/// Software encoder (libx265)
|
||||||
Software,
|
Software,
|
||||||
/// No encoder available
|
/// No encoder available
|
||||||
@@ -61,6 +63,7 @@ impl std::fmt::Display for H265EncoderType {
|
|||||||
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||||
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||||
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||||
|
H265EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||||
H265EncoderType::Software => write!(f, "Software"),
|
H265EncoderType::Software => write!(f, "Software"),
|
||||||
H265EncoderType::None => write!(f, "None"),
|
H265EncoderType::None => write!(f, "None"),
|
||||||
}
|
}
|
||||||
@@ -76,6 +79,7 @@ impl From<EncoderBackend> for H265EncoderType {
|
|||||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||||
|
EncoderBackend::MediaCodec => H265EncoderType::MediaCodec,
|
||||||
EncoderBackend::Software => H265EncoderType::Software,
|
EncoderBackend::Software => H265EncoderType::Software,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,10 +247,10 @@ pub fn is_h265_available() -> bool {
|
|||||||
registry.is_codec_available(VideoEncoderType::H265)
|
registry.is_codec_available(VideoEncoderType::H265)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded frame from hwcodec (cloned for ownership)
|
/// Encoded frame from hwcodec.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HwEncodeFrame {
|
pub struct HwEncodeFrame {
|
||||||
pub data: Vec<u8>,
|
pub data: Bytes,
|
||||||
pub pts: i64,
|
pub pts: i64,
|
||||||
pub key: i32,
|
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) => {
|
Ok(frames) => {
|
||||||
// Zero-copy: drain frames from hwcodec buffer instead of cloning
|
|
||||||
let owned_frames: Vec<HwEncodeFrame> = frames
|
let owned_frames: Vec<HwEncodeFrame> = frames
|
||||||
.drain(..)
|
.into_iter()
|
||||||
.map(|f| HwEncodeFrame {
|
.map(|f| HwEncodeFrame {
|
||||||
data: f.data, // Move, not clone
|
data: f.data,
|
||||||
pts: f.pts,
|
pts: f.pts,
|
||||||
key: f.key,
|
key: f.key,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<Self> {
|
|
||||||
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<Vec<u8>> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
use hwcodec::common::DataFormat;
|
use hwcodec::common::DataFormat;
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
|
#[cfg(feature = "android-mediacodec")]
|
||||||
|
pub mod android_mediacodec;
|
||||||
|
#[cfg(feature = "android-mediacodec")]
|
||||||
|
pub mod android_mjpeg;
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
|
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
@@ -16,16 +20,17 @@ pub mod video_codec;
|
|||||||
pub mod vp8;
|
pub mod vp8;
|
||||||
pub mod vp9;
|
pub mod vp9;
|
||||||
|
|
||||||
pub mod mjpeg_turbo;
|
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
||||||
|
|
||||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
|
||||||
pub mod mjpeg_rkmpp;
|
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 h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||||
pub use jpeg::JpegEncoder;
|
pub use jpeg::JpegEncoder;
|
||||||
pub use mjpeg_turbo::MjpegTurboDecoder;
|
|
||||||
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
pub use registry::{AvailableEncoder, EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
pub use self_check::{
|
pub use self_check::{
|
||||||
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
build_hardware_self_check_runtime_error, run_hardware_self_check, VideoEncoderSelfCheckCell,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ pub enum EncoderBackend {
|
|||||||
Rkmpp,
|
Rkmpp,
|
||||||
/// V4L2 Memory-to-Memory (ARM)
|
/// V4L2 Memory-to-Memory (ARM)
|
||||||
V4l2m2m,
|
V4l2m2m,
|
||||||
|
/// Android MediaCodec via FFmpeg
|
||||||
|
MediaCodec,
|
||||||
/// Software encoding (libx264, libx265, libvpx)
|
/// Software encoding (libx264, libx265, libvpx)
|
||||||
Software,
|
Software,
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,8 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Rkmpp
|
EncoderBackend::Rkmpp
|
||||||
} else if name.contains("v4l2m2m") {
|
} else if name.contains("v4l2m2m") {
|
||||||
EncoderBackend::V4l2m2m
|
EncoderBackend::V4l2m2m
|
||||||
|
} else if name.contains("mediacodec") {
|
||||||
|
EncoderBackend::MediaCodec
|
||||||
} else {
|
} else {
|
||||||
EncoderBackend::Software
|
EncoderBackend::Software
|
||||||
}
|
}
|
||||||
@@ -134,6 +138,7 @@ impl EncoderBackend {
|
|||||||
EncoderBackend::Amf => "AMF",
|
EncoderBackend::Amf => "AMF",
|
||||||
EncoderBackend::Rkmpp => "RKMPP",
|
EncoderBackend::Rkmpp => "RKMPP",
|
||||||
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
||||||
|
EncoderBackend::MediaCodec => "MediaCodec",
|
||||||
EncoderBackend::Software => "Software",
|
EncoderBackend::Software => "Software",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +153,7 @@ impl EncoderBackend {
|
|||||||
"amf" => Some(EncoderBackend::Amf),
|
"amf" => Some(EncoderBackend::Amf),
|
||||||
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
||||||
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
||||||
|
"mediacodec" | "android-mediacodec" => Some(EncoderBackend::MediaCodec),
|
||||||
"software" | "cpu" => Some(EncoderBackend::Software),
|
"software" | "cpu" => Some(EncoderBackend::Software),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use serde::Serialize;
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[cfg(feature = "android-mediacodec")]
|
||||||
|
use super::AndroidMediaCodecH264Encoder;
|
||||||
use super::{
|
use super::{
|
||||||
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||||
VP9Config, VP9Encoder, VideoEncoderType,
|
VP9Config, VP9Encoder, VideoEncoderType,
|
||||||
@@ -235,6 +237,32 @@ fn run_smoke_test(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||||
|
#[cfg(feature = "android-mediacodec")]
|
||||||
|
if codec_name_ffmpeg == "h264_mediacodec" {
|
||||||
|
let mut encoder = AndroidMediaCodecH264Encoder::new(
|
||||||
|
resolution,
|
||||||
|
PixelFormat::Nv12,
|
||||||
|
30,
|
||||||
|
bitrate_kbps_for_resolution(resolution),
|
||||||
|
)?;
|
||||||
|
encoder.request_keyframe();
|
||||||
|
let frame = build_nv12_test_frame(
|
||||||
|
resolution,
|
||||||
|
PixelFormat::Nv12.frame_size(resolution).unwrap_or(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||||
|
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||||
|
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(AppError::VideoError(
|
||||||
|
"Encoder produced no output after multiple frames".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut encoder = H264Encoder::with_codec(
|
let mut encoder = H264Encoder::with_codec(
|
||||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||||
codec_name_ffmpeg,
|
codec_name_ffmpeg,
|
||||||
|
|||||||
@@ -898,6 +898,17 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
|||||||
candidates.push(path);
|
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);
|
collapse_rkcif_probe_candidates(&mut candidates);
|
||||||
|
|
||||||
// Second pass: probe the remaining candidates in parallel. Each probe
|
// Second pass: probe the remaining candidates in parallel. Each probe
|
||||||
@@ -952,11 +963,35 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
|||||||
// for a single MIPI CSI pipeline. Keep only the highest-priority node per
|
// for a single MIPI CSI pipeline. Keep only the highest-priority node per
|
||||||
// (driver, bus_info) group so users see one device instead of ~11.
|
// (driver, bus_info) group so users see one device instead of ~11.
|
||||||
dedup_platform_subdevices(&mut devices);
|
dedup_platform_subdevices(&mut devices);
|
||||||
|
devices.retain(|device| {
|
||||||
|
let hide = should_hide_android_platform_node(device);
|
||||||
|
if hide {
|
||||||
|
debug!(
|
||||||
|
"Hiding Android platform video node: {} ({}) {}",
|
||||||
|
device.name,
|
||||||
|
device.driver,
|
||||||
|
device.path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
!hide
|
||||||
|
});
|
||||||
|
|
||||||
info!("Found {} video capture devices", devices.len());
|
info!("Found {} video capture devices", devices.len());
|
||||||
Ok(devices)
|
Ok(devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn video_node_names(dir: &str) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = 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(
|
pub fn select_recovery_device(
|
||||||
devices: &[VideoDeviceInfo],
|
devices: &[VideoDeviceInfo],
|
||||||
hint: &VideoDeviceRecoveryHint,
|
hint: &VideoDeviceRecoveryHint,
|
||||||
@@ -1020,6 +1055,33 @@ fn dedup_platform_subdevices(devices: &mut Vec<VideoDeviceInfo>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_hide_android_platform_node(device: &VideoDeviceInfo) -> bool {
|
||||||
|
if !cfg!(feature = "android") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let driver = device.driver.to_ascii_lowercase();
|
||||||
|
let name = device.name.to_ascii_lowercase();
|
||||||
|
let card = device.card.to_ascii_lowercase();
|
||||||
|
let usb_device = driver == "uvcvideo" || device.bus_info.starts_with("usb-");
|
||||||
|
let known_bridge =
|
||||||
|
driver.contains("rkcif") || driver.contains("rk_hdmirx") || driver.contains("tc358743");
|
||||||
|
if usb_device || known_bridge {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches!(
|
||||||
|
driver.as_str(),
|
||||||
|
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||||
|
) || matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||||
|
) || matches!(
|
||||||
|
card.as_str(),
|
||||||
|
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// rkcif registers many `/dev/video*` queues; probing all in parallel can
|
/// rkcif registers many `/dev/video*` queues; probing all in parallel can
|
||||||
/// contend and time out. Keep one node per board (lowest `videoN`).
|
/// contend and time out. Keep one node per board (lowest `videoN`).
|
||||||
fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) {
|
fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) {
|
||||||
@@ -1123,6 +1185,20 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
|||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
let driver = extract_uevent_value(&uevent, "driver");
|
let driver = extract_uevent_value(&uevent, "driver");
|
||||||
|
|
||||||
|
if cfg!(feature = "android") {
|
||||||
|
let platform_skip = ["ionvideo", "amlvideo", "amlvideo2", "videosync"];
|
||||||
|
let driver_skip = driver
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|driver| platform_skip.iter().any(|hint| driver == hint));
|
||||||
|
if driver_skip || platform_skip.iter().any(|hint| sysfs_name == *hint) {
|
||||||
|
debug!(
|
||||||
|
"Skipping Android platform video node {:?}: {}",
|
||||||
|
path, sysfs_name
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut maybe_capture = false;
|
let mut maybe_capture = false;
|
||||||
let capture_hints = [
|
let capture_hints = [
|
||||||
"capture",
|
"capture",
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ mod linux;
|
|||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub use linux::*;
|
pub use linux::{
|
||||||
|
enumerate_devices, find_best_device, select_recovery_device, VideoDevice, VideoDeviceInfo,
|
||||||
|
VideoDeviceRecoveryHint,
|
||||||
|
};
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use 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 {
|
pub(crate) fn is_csi_hdmi_bridge(device: &VideoDeviceInfo) -> bool {
|
||||||
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
|
is_rk_hdmirx_device(device) || is_rkcif_driver(&device.driver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) use linux::parse_bridge_kind;
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ pub mod codec_constraints;
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod stream_manager;
|
pub mod stream_manager;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod streamer;
|
pub mod streamer;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||||
pub mod types;
|
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 codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer};
|
||||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||||
pub use format::PixelFormat;
|
pub use format::PixelFormat;
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
use crate::error::{AppError, Result};
|
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::h264::{H264Config, H264Encoder, H264InputFormat};
|
||||||
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
use crate::video::codec::h265::{H265Config, H265Encoder, H265InputFormat};
|
||||||
use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncoderType};
|
||||||
use crate::video::codec::traits::EncoderConfig;
|
use crate::video::codec::traits::EncoderConfig;
|
||||||
use crate::video::codec::vp8::{VP8Config, VP8Encoder};
|
use crate::video::codec::vp8::{VP8Config, VP8Encoder};
|
||||||
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
|
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
|
||||||
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};
|
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::{
|
use hwcodec::ffmpeg_hw::{
|
||||||
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
|
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
|
||||||
};
|
};
|
||||||
@@ -22,9 +29,15 @@ pub(super) struct EncoderThreadState {
|
|||||||
pub(super) nv12_converter: Option<Nv12Converter>,
|
pub(super) nv12_converter: Option<Nv12Converter>,
|
||||||
pub(super) yuv420p_converter: Option<PixelConverter>,
|
pub(super) yuv420p_converter: Option<PixelConverter>,
|
||||||
pub(super) encoder_needs_yuv420p: bool,
|
pub(super) encoder_needs_yuv420p: bool,
|
||||||
#[cfg(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<HwMjpegH26xPipeline>,
|
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
|
||||||
#[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) ffmpeg_hw_enabled: bool,
|
||||||
pub(super) fps: u32,
|
pub(super) fps: u32,
|
||||||
pub(super) codec: VideoEncoderType,
|
pub(super) codec: VideoEncoderType,
|
||||||
@@ -39,7 +52,7 @@ pub(super) trait VideoEncoderTrait: Send {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct EncodedFrame {
|
pub(super) struct EncodedFrame {
|
||||||
pub(super) data: Vec<u8>,
|
pub(super) data: Bytes,
|
||||||
pub(super) key: i32,
|
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<Box<dyn VideoEncoderTrait + Send>> {
|
||||||
|
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);
|
struct H265EncoderWrapper(H265Encoder);
|
||||||
|
|
||||||
impl VideoEncoderTrait for H265EncoderWrapper {
|
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<Vec<EncodedFrame>> {
|
||||||
|
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||||
|
Ok(frames
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| EncodedFrame {
|
||||||
|
data: f.data,
|
||||||
|
key: if f.key_frame { 1 } else { 0 },
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||||
|
self.0.set_bitrate(bitrate_kbps)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_name(&self) -> &str {
|
||||||
|
self.0.codec_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_keyframe(&mut self) {
|
||||||
|
self.0.request_keyframe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct VP8EncoderWrapper(VP8Encoder);
|
struct VP8EncoderWrapper(VP8Encoder);
|
||||||
|
|
||||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||||
@@ -105,7 +166,7 @@ impl VideoEncoderTrait for VP8EncoderWrapper {
|
|||||||
Ok(frames
|
Ok(frames
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| EncodedFrame {
|
.map(|f| EncodedFrame {
|
||||||
data: f.data,
|
data: f.data.into(),
|
||||||
key: f.key,
|
key: f.key,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -130,7 +191,7 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
|||||||
Ok(frames
|
Ok(frames
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| EncodedFrame {
|
.map(|f| EncodedFrame {
|
||||||
data: f.data,
|
data: f.data.into(),
|
||||||
key: f.key,
|
key: f.key,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -148,17 +209,100 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) enum MjpegDecoderKind {
|
pub(super) enum MjpegDecoderKind {
|
||||||
Turbo(MjpegTurboDecoder),
|
#[cfg(feature = "android-mediacodec")]
|
||||||
|
AndroidMediaCodec {
|
||||||
|
decoder: AndroidMediaCodecMjpegDecoder,
|
||||||
|
fallback: Box<MjpegDecoderKind>,
|
||||||
|
fallback_active: bool,
|
||||||
|
output: Vec<u8>,
|
||||||
|
},
|
||||||
|
Libyuv {
|
||||||
|
decoder: MjpegToNv12Decoder,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MjpegDecoderKind {
|
impl MjpegDecoderKind {
|
||||||
pub(super) fn decode(&mut self, data: &[u8]) -> Result<Vec<u8>> {
|
pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> {
|
||||||
match self {
|
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(
|
pub(super) fn build_encoder_state(
|
||||||
config: &SharedVideoPipelineConfig,
|
config: &SharedVideoPipelineConfig,
|
||||||
) -> Result<EncoderThreadState> {
|
) -> Result<EncoderThreadState> {
|
||||||
@@ -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");
|
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
|
if needs_mjpeg_decode
|
||||||
&& is_rkmpp_encoder
|
&& is_rkmpp_encoder
|
||||||
&& matches!(
|
&& matches!(
|
||||||
@@ -298,9 +448,15 @@ pub(super) fn build_encoder_state(
|
|||||||
nv12_converter: None,
|
nv12_converter: None,
|
||||||
yuv420p_converter: None,
|
yuv420p_converter: None,
|
||||||
encoder_needs_yuv420p: false,
|
encoder_needs_yuv420p: false,
|
||||||
#[cfg(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),
|
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,
|
ffmpeg_hw_enabled: true,
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
codec: config.output_codec,
|
codec: config.output_codec,
|
||||||
@@ -309,16 +465,8 @@ pub(super) fn build_encoder_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode {
|
let (mjpeg_decoder, pipeline_input_format) = if needs_mjpeg_decode {
|
||||||
info!(
|
let (decoder, format) = create_mjpeg_decoder(config.resolution)?;
|
||||||
"MJPEG input detected, using TurboJPEG decoder ({} -> RGB24)",
|
(Some(decoder), format)
|
||||||
config.input_format
|
|
||||||
);
|
|
||||||
(
|
|
||||||
Some(MjpegDecoderKind::Turbo(MjpegTurboDecoder::new(
|
|
||||||
config.resolution,
|
|
||||||
)?)),
|
|
||||||
PixelFormat::Rgb24,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
(None, config.input_format)
|
(None, config.input_format)
|
||||||
};
|
};
|
||||||
@@ -347,18 +495,40 @@ pub(super) fn build_encoder_state(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let encoder = H264Encoder::with_codec(
|
#[cfg(feature = "android-mediacodec")]
|
||||||
H264Config {
|
{
|
||||||
base: EncoderConfig::h264(config.resolution, config.bitrate_kbps()),
|
if codec_name == "h264_mediacodec" {
|
||||||
bitrate_kbps: config.bitrate_kbps(),
|
info!(
|
||||||
gop_size: config.gop_size(),
|
"Creating Android MediaCodec H264 encoder for {:?} input",
|
||||||
fps: config.fps,
|
input_format
|
||||||
input_format,
|
);
|
||||||
},
|
let pixel_format = match input_format {
|
||||||
&codec_name,
|
H264InputFormat::Nv12 => PixelFormat::Nv12,
|
||||||
)?;
|
H264InputFormat::Yuv420p => PixelFormat::Yuv420,
|
||||||
info!("Created H264 encoder: {}", encoder.codec_name());
|
other => {
|
||||||
Box::new(H264EncoderWrapper(encoder))
|
return Err(AppError::VideoError(format!(
|
||||||
|
"Android MediaCodec H264 does not support {:?} direct input",
|
||||||
|
other
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let encoder = AndroidMediaCodecH264Encoder::new(
|
||||||
|
config.resolution,
|
||||||
|
pixel_format,
|
||||||
|
config.fps,
|
||||||
|
config.bitrate_kbps(),
|
||||||
|
)?;
|
||||||
|
info!("Created Android MediaCodec H264 encoder");
|
||||||
|
Box::new(AndroidMediaCodecH264EncoderWrapper(encoder))
|
||||||
|
} else {
|
||||||
|
create_h264_encoder(config, input_format, &codec_name)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "android-mediacodec"))]
|
||||||
|
{
|
||||||
|
create_h264_encoder(config, input_format, &codec_name)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
VideoEncoderType::H265 => {
|
VideoEncoderType::H265 => {
|
||||||
let codec_name = selected_codec_name.clone();
|
let codec_name = selected_codec_name.clone();
|
||||||
@@ -452,6 +622,11 @@ pub(super) fn build_encoder_state(
|
|||||||
pipeline_input_format,
|
pipeline_input_format,
|
||||||
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||||
)
|
)
|
||||||
|
} else if codec_name.contains("mediacodec") {
|
||||||
|
matches!(
|
||||||
|
pipeline_input_format,
|
||||||
|
PixelFormat::Nv12 | PixelFormat::Yuv420
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
@@ -501,9 +676,15 @@ pub(super) fn build_encoder_state(
|
|||||||
nv12_converter,
|
nv12_converter,
|
||||||
yuv420p_converter,
|
yuv420p_converter,
|
||||||
encoder_needs_yuv420p: needs_yuv420p,
|
encoder_needs_yuv420p: needs_yuv420p,
|
||||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
#[cfg(all(
|
||||||
|
any(target_arch = "aarch64", target_arch = "arm"),
|
||||||
|
not(target_os = "android")
|
||||||
|
))]
|
||||||
ffmpeg_hw_pipeline: None,
|
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,
|
ffmpeg_hw_enabled: false,
|
||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
codec: config.output_codec,
|
codec: config.output_codec,
|
||||||
@@ -527,6 +708,12 @@ fn h264_direct_input_format(
|
|||||||
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
} else if codec_name.contains("mediacodec") {
|
||||||
|
match input_format {
|
||||||
|
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||||
|
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
} else if codec_name.contains("libx264") {
|
} else if codec_name.contains("libx264") {
|
||||||
match input_format {
|
match input_format {
|
||||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const CSI_BRIDGE_NOSIGNAL_INTERVAL_MS: u64 = 500;
|
|||||||
const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20);
|
const NOSIGNAL_POLL_MAX: Duration = Duration::from_secs(20);
|
||||||
/// Throttle repeated encoding errors to avoid log flooding
|
/// Throttle repeated encoding errors to avoid log flooding
|
||||||
const ENCODE_ERROR_THROTTLE_SECS: u64 = 5;
|
const ENCODE_ERROR_THROTTLE_SECS: u64 = 5;
|
||||||
|
const INVALID_MJPEG_LOG_THROTTLE_SECS: u64 = 5;
|
||||||
|
|
||||||
static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
@@ -60,7 +61,10 @@ use crate::video::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
|||||||
use crate::video::signal::SignalStatus;
|
use crate::video::signal::SignalStatus;
|
||||||
|
|
||||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
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;
|
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
|
||||||
|
|
||||||
/// Encoded video frame for distribution
|
/// Encoded video frame for distribution
|
||||||
@@ -480,9 +484,15 @@ impl SharedVideoPipeline {
|
|||||||
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
|
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
|
||||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
#[cfg(any(
|
||||||
|
not(any(target_arch = "aarch64", target_arch = "arm")),
|
||||||
|
target_os = "android"
|
||||||
|
))]
|
||||||
let _ = gop;
|
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 state.ffmpeg_hw_enabled {
|
||||||
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
||||||
pipeline
|
pipeline
|
||||||
@@ -649,12 +659,14 @@ impl SharedVideoPipeline {
|
|||||||
*guard = Some(cmd_tx);
|
*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 pipeline = pipeline.clone();
|
||||||
let latest_frame = latest_frame.clone();
|
let latest_frame = latest_frame.clone();
|
||||||
tokio::spawn(async move {
|
let handle = tokio::runtime::Handle::current();
|
||||||
let mut frame_count: u64 = 0;
|
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 last_fps_time = Instant::now();
|
||||||
let mut fps_frame_count: u64 = 0;
|
let mut fps_frame_count: u64 = 0;
|
||||||
let mut last_seq = *frame_seq_rx.borrow();
|
let mut last_seq = *frame_seq_rx.borrow();
|
||||||
@@ -662,7 +674,7 @@ impl SharedVideoPipeline {
|
|||||||
let mut suppressed_encode_errors: HashMap<String, u64> = HashMap::new();
|
let mut suppressed_encode_errors: HashMap<String, u64> = HashMap::new();
|
||||||
|
|
||||||
while pipeline.running_flag.load(Ordering::Acquire) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
if !pipeline.running_flag.load(Ordering::Acquire) {
|
if !pipeline.running_flag.load(Ordering::Acquire) {
|
||||||
@@ -694,15 +706,19 @@ impl SharedVideoPipeline {
|
|||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
match pipeline.encode_frame_sync(&mut encoder_state, &frame, frame_count) {
|
input_frame_count = input_frame_count.wrapping_add(1);
|
||||||
Ok(Some(encoded_frame)) => {
|
|
||||||
let encoded_arc = Arc::new(encoded_frame);
|
|
||||||
pipeline.broadcast_encoded(encoded_arc).await;
|
|
||||||
|
|
||||||
frame_count += 1;
|
match pipeline.encode_frame_sync(&mut encoder_state, &frame, input_frame_count)
|
||||||
fps_frame_count += 1;
|
{
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
log_encoding_error(
|
log_encoding_error(
|
||||||
&encode_error_throttler,
|
&encode_error_throttler,
|
||||||
@@ -718,8 +734,15 @@ impl SharedVideoPipeline {
|
|||||||
fps_frame_count = 0;
|
fps_frame_count = 0;
|
||||||
last_fps_time = Instant::now();
|
last_fps_time = Instant::now();
|
||||||
|
|
||||||
let mut s = pipeline.stats.lock().await;
|
handle.block_on(async {
|
||||||
s.current_fps = current_fps;
|
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 sequence: u64 = 0;
|
||||||
let mut consecutive_timeouts: u32 = 0;
|
let mut consecutive_timeouts: u32 = 0;
|
||||||
let capture_error_throttler = LogThrottler::with_secs(5);
|
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<String, u64> = HashMap::new();
|
let mut suppressed_capture_errors: HashMap<String, u64> = HashMap::new();
|
||||||
|
|
||||||
while pipeline.running_flag.load(Ordering::Acquire) {
|
while pipeline.running_flag.load(Ordering::Acquire) {
|
||||||
@@ -1207,6 +1232,20 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
owned.truncate(frame_size);
|
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 —
|
// Notify streaming only after frame validation passes —
|
||||||
// stale/warm-up frames from V4L2 kernel queues can cause
|
// stale/warm-up frames from V4L2 kernel queues can cause
|
||||||
// DQBUF Ok with invalid data, which would prematurely
|
// DQBUF Ok with invalid data, which would prematurely
|
||||||
@@ -1244,7 +1283,7 @@ impl SharedVideoPipeline {
|
|||||||
state: &mut EncoderThreadState,
|
state: &mut EncoderThreadState,
|
||||||
frame: &VideoFrame,
|
frame: &VideoFrame,
|
||||||
frame_count: u64,
|
frame_count: u64,
|
||||||
) -> Result<Option<EncodedVideoFrame>> {
|
) -> Result<Vec<EncodedVideoFrame>> {
|
||||||
let fps = state.fps;
|
let fps = state.fps;
|
||||||
let codec = state.codec;
|
let codec = state.codec;
|
||||||
let input_format = state.input_format;
|
let input_format = state.input_format;
|
||||||
@@ -1268,7 +1307,10 @@ impl SharedVideoPipeline {
|
|||||||
current_ts_us.saturating_sub(start_ts_us) / 1000
|
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 state.ffmpeg_hw_enabled {
|
||||||
if input_format != PixelFormat::Mjpeg {
|
if input_format != PixelFormat::Mjpeg {
|
||||||
return Err(AppError::VideoError(
|
return Err(AppError::VideoError(
|
||||||
@@ -1295,17 +1337,17 @@ impl SharedVideoPipeline {
|
|||||||
|
|
||||||
if let Some((data, is_keyframe)) = packet {
|
if let Some((data, is_keyframe)) = packet {
|
||||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
return Ok(Some(EncodedVideoFrame {
|
return Ok(vec![EncodedVideoFrame {
|
||||||
data: Bytes::from(data),
|
data: Bytes::from(data),
|
||||||
pts_ms,
|
pts_ms,
|
||||||
is_keyframe,
|
is_keyframe,
|
||||||
sequence,
|
sequence,
|
||||||
duration: Duration::from_millis(1000 / fps as u64),
|
duration: Duration::from_millis(1000 / fps as u64),
|
||||||
codec,
|
codec,
|
||||||
}));
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoded_buf = if input_format.is_compressed() {
|
let decoded_buf = if input_format.is_compressed() {
|
||||||
@@ -1313,12 +1355,26 @@ impl SharedVideoPipeline {
|
|||||||
.mjpeg_decoder
|
.mjpeg_decoder
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| AppError::VideoError("MJPEG decoder not initialized".to_string()))?;
|
.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)
|
Some(decoded)
|
||||||
} else {
|
} else {
|
||||||
None
|
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
|
// Debug log for H265
|
||||||
if codec == VideoEncoderType::H265 && frame_count % 30 == 1 {
|
if codec == VideoEncoderType::H265 && frame_count % 30 == 1 {
|
||||||
@@ -1365,8 +1421,24 @@ impl SharedVideoPipeline {
|
|||||||
|
|
||||||
match encode_result {
|
match encode_result {
|
||||||
Ok(frames) => {
|
Ok(frames) => {
|
||||||
if !frames.is_empty() {
|
if frames.is_empty() {
|
||||||
let encoded = frames.into_iter().next().unwrap();
|
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 is_keyframe = encoded.key == 1;
|
||||||
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
if codec == VideoEncoderType::H264 {
|
if codec == VideoEncoderType::H264 {
|
||||||
@@ -1390,23 +1462,17 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some(EncodedVideoFrame {
|
encoded_frames.push(EncodedVideoFrame {
|
||||||
data: Bytes::from(encoded.data),
|
data: encoded.data,
|
||||||
pts_ms,
|
pts_ms,
|
||||||
is_keyframe,
|
is_keyframe,
|
||||||
sequence,
|
sequence,
|
||||||
duration: Duration::from_millis(1000 / fps as u64),
|
duration: Duration::from_millis(1000 / fps as u64),
|
||||||
codec,
|
codec,
|
||||||
}))
|
});
|
||||||
} else {
|
|
||||||
if codec == VideoEncoderType::H265 {
|
|
||||||
warn!(
|
|
||||||
"[Pipeline-H265] Encoder returned no frames for frame #{}",
|
|
||||||
frame_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(encoded_frames)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if codec == VideoEncoderType::H265 {
|
if codec == VideoEncoderType::H265 {
|
||||||
@@ -1490,6 +1556,174 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compact_strided_frame_for_encoder(frame: &VideoFrame, data: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||||
|
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 {
|
impl Drop for SharedVideoPipeline {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.running.send(false);
|
let _ = self.running.send(false);
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ pub async fn apply_rustdesk_config(
|
|||||||
let mut rustdesk_guard = state.rustdesk.write().await;
|
let mut rustdesk_guard = state.rustdesk.write().await;
|
||||||
let mut credentials_to_save = None;
|
let mut credentials_to_save = None;
|
||||||
|
|
||||||
if old_config.enabled && !new_config.enabled {
|
if !new_config.enabled {
|
||||||
if let Some(ref service) = *rustdesk_guard {
|
if let Some(ref service) = *rustdesk_guard {
|
||||||
service
|
service
|
||||||
.stop()
|
.stop()
|
||||||
@@ -493,7 +493,7 @@ pub async fn apply_rtsp_config(
|
|||||||
|
|
||||||
let mut rtsp_guard = state.rtsp.write().await;
|
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 {
|
if let Some(ref service) = *rtsp_guard {
|
||||||
service
|
service
|
||||||
.stop()
|
.stop()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user