diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b49fa86 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,309 @@ +# AirDisplay 系统架构 + +## 概览 + +三协议并行架构,根据场景自动选择或用户手动指定: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Android 端 (AirDisplay) │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ 协议栈 & 自动选择 ││ +│ │ ││ +│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐││ +│ │ │ Miracast │ │ ADP │ │ AirPlay │││ +│ │ │ (保留) │ │ (私有协议) │ │ (Mac 原生) │││ +│ │ │ │ │ │ │ │││ +│ │ │ WFD IE │ │ mDNS 发现 │ │ UxPlay 移植 │││ +│ │ │ RTSP→RTP→TS │ │ TCP 帧协议 │ │ H.264+AAC │││ +│ │ │ Wi-Fi Direct │ │ P2P/LAN │ │ FairPlay │││ +│ │ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘││ +│ │ └────────────────┼──────────────────┘ ││ +│ │ ▼ ││ +│ │ 共用 MediaCodec 解码 + 渲染 ││ +│ │ UIBC 触摸回传 (已有) ││ +│ │ DeviceName 配置 (已有) ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ +│ 自动选择逻辑: │ +│ Wi-Fi Direct + WFD IE 可用 → Miracast │ +│ Wi-Fi Direct + WFD IE 失败 → 降级 ADP (P2P) │ +│ 同局域网 → ADP (LAN) │ +│ Mac 投屏请求 → AirPlay │ +│ 用户手动选择 → 指定协议 │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Desktop Client (独立仓库 airdisplay-client) │ +│ │ +│ 技术栈: Rust + Tauri v2 │ +│ 目标平台: Windows / macOS / Linux │ +│ │ +│ ┌───────────┐ ┌────────────┐ ┌─────────────────────────┐ │ +│ │ 屏幕采集 │→ │ 视频编码 │→ │ 网络层 │ │ +│ │ │ │ │ │ │ │ +│ │ Windows │ │ NVENC │ │ mDNS 发现 (mdns-sd) │ │ +│ │ DXGI │ │ (NVIDIA) │ │ ADP 协议 (自定义帧协议) │ │ +│ │ macOS │ │ Video │ │ TCP 可靠传输 │ │ +│ │ CGDisplay│ │ Toolbox │ │ │ │ +│ │ Linux │ │ Intel QSV │ │ QUIC 备选 (低延迟) │ │ +│ │ PipeWire │ │ x264 软编 │ │ │ │ +│ └───────────┘ └────────────┘ └─────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ Tauri UI (React/TypeScript) ││ +│ │ ├─ 自动发现设备列表 (mDNS) ││ +│ │ ├─ 连接/断开控制 ││ +│ │ ├─ 协议选择 (ADP 默认 / Miracast 备选) ││ +│ │ ├─ 分辨率/帧率/延迟模式设置 ││ +│ │ └─ 连接状态 & 统计信息 ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Mac 端 (无额外客户端) │ +│ │ +│ 控制中心 → 屏幕镜像 → 选择 AirDisplay │ +│ 使用 Apple 内置 AirPlay 协议 │ +│ 无需安装额外软件 │ +│ 备选:也可安装 airdisplay-client 走 ADP 协议 │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 协议设计 + +### 1. Miracast (已实现,保留) + +现有代码保持不变: +- `WiFiDirectManager.kt` — P2P Group 创建 + WFD IE 反射 (失败不阻塞) +- `RtspServer.kt` / `RtspSession.kt` — RTSP 握手 +- `RtpReceiver.kt` / `TsDemuxer.kt` — RTP/TS 媒体管道 +- 用于 Android TV / 系统签名设备 / WFD IE 兼容的环境 + +**改动**:WFD IE 反射失败时不再终止流程,自动降级到 ADP。 + +### 2. AirDisplay Protocol (ADP) v1 + +自定义轻量屏幕投屏协议。设计目标:低延迟、简单可靠、跨平台。 + +#### 发现阶段 + +| 场景 | 发现方式 | +|------|---------| +| Wi‑Fi Direct P2P | P2P Group 创建后,在 `192.168.49.1:7935` 启动 TCP 监听 | +| 同局域网 | mDNS 注册 `_airdisplay._tcp` 端口 7935 | + +#### 连接阶段 (TCP 7935) + +``` +[Client] ──TCP 连接──> [Server] + +[Server] ADP/1.0 200 OK + Capabilities: { + "max_width": 1920, + "max_height": 1080, + "codecs": ["h264"], + "features": ["uibc", "audio"] + } + +[Client] ADP/1.0 HANDSHAKE + Settings: { + "width": 1920, + "height": 1080, + "fps": 30, + "codec": "h264", + "bitrate": 20000000 + } + +[Server] ADP/1.0 READY +``` + +#### 数据阶段 (TCP 持久连接) + +帧格式: +``` +┌──────────────────────────────────────────────────────────┐ +│ Type(1) │ Reserved(1) │ Length(2) │ Timestamp(4) │ +├──────────────────────────────────────────────────────────┤ +│ Payload (变长) │ +└──────────────────────────────────────────────────────────┘ + +Type 定义: + 0x01 = Capability 协商 (JSON) + 0x02 = H.264 NAL 单元 (Annex B) + 0x03 = H.265 NAL 单元 (保留) + 0x04 = 音频帧 (AAC/Opus) + 0x05 = UIBC 事件 (触摸/鼠标/键盘) + 0x06 = Keepalive + 0x07 = 分辨率/参数变更 +``` + +#### UIBC 格式 + +与现有 `UibcServer.kt` 兼容。 + +### 3. AirPlay + +使用 [UxPlay](https://github.com/FDH2/UxPlay) (MIT License) 移植到 Android NDK。 + +UxPlay 是一个开源的 AirPlay 接收端实现,支持: +- AirPlay Mirroring (H.264 视频) +- AirPlay Audio (ALAC/AAC) +- FairPlay 解密 (需要系统证书) +- macOS/iOS 屏幕镜像 & 扩展 + +**移植策略**: +1. `airdisplay/native/uxplay/` — CMakeLists.txt + 源码 +2. JNI 桥接 — UxPlay 的 H.264 NAL 通过 JNI 传回 Android 层 +3. 共享 `MediaRenderer.kt` — 与 ADP/Miracast 共用 MediaCodec 解码 + +--- + +## Android 端代码结构 + +``` +app/src/main/java/com/qwenpaw/miracast/ +├── AirDisplayService.kt ← 前台服务 (改自 MiracastService) +├── protocol/ +│ ├── ProtocolManager.kt ← 协议选择逻辑 (新增) +│ ├── AirDisplayProtocol.kt ← ADP 协议处理器 (新增) +│ ├── MdnsService.kt ← mDNS 注册 (新增) +│ └── AirPlayBridge.kt ← AirPlay JNI 桥接 (新增) +├── wifidirect/ +│ └── WiFiDirectManager.kt ← P2P 管理 (改造) +├── rtsp/ ← 保留 (Miracast 用) +│ ├── RtspServer.kt +│ ├── RtspSession.kt +│ └── WfdParser.kt +├── media/ +│ ├── RtpReceiver.kt +│ ├── TsDemuxer.kt +│ └── MediaRenderer.kt ← 共享解码+渲染 +├── uibc/ +│ └── UibcServer.kt +├── DeviceNameManager.kt +├── ResolutionManager.kt +├── LatencyManager.kt +└── ui/ + ├── MainActivity.kt + ├── SettingsActivity.kt + └── ... +``` + +### 网络拓扑 + +``` +P2P 模式: 手机(GO) ←── Wi‑Fi Direct ──→ PC + IP: 192.168.49.1 + +LAN 模式: 手机 ──── Wi‑Fi Router ────→ PC + ↑ mDNS ↑ mDNS +``` + +--- + +## Desktop Client 技术方案 + +### 技术栈 + +| 层面 | 方案 | 说明 | +|------|------|------| +| 语言 | **Rust** | 跨平台原生、零开销抽象、安全 | +| 桌面框架 | **Tauri v2** | Rust 后端 + Web 前端,打包 < 10MB | +| 异步运行时 | **tokio** | 全异步 I/O | +| 前端框架 | **React + TypeScript** | 组件化 UI | +| 构建工具 | **Vite** | HMR 快速开发 | + +### 屏幕采集 + +| 平台 | 方案 | Rust crate | +|------|------|-----------| +| Windows | DXGI Desktop Duplication | `dxgi-rs` / `captrs` | +| macOS | CGDisplay Stream | `core-graphics` (sys) | +| Linux (Wayland) | PipeWire | `pipewire-rs` | +| Linux (X11) | X11 SHM GetImage | `x11` / `scrap` | + +### 视频编码 + +| 编码器 | 方式 | 适用平台 | +|--------|------|---------| +| NVENC | 硬件 | Windows (NVIDIA) | +| VideoToolbox | 硬件 | macOS | +| Intel QSV | 硬件 | Windows/Linux (Intel) | +| x264 | 软件 | 全平台保底 | +| VAAPI | 硬件 | Linux (AMD/Intel) | + +### 协议实现 + +- `adp-client` crate — ADP 协议客户端库 + - TCP 连接管理 + 重连 + - 帧序列化 (Header + Payload) + - Keepalive + - UIBC 事件发送 +- `mdns-resolver` — mDNS 设备发现 + - 监听 `_airdisplay._tcp` 服务 + - 自动刷新设备列表 + +### Tauri UI 功能 + +- **发现页**: 自动扫描局域网内 AirDisplay 设备 +- **连接页**: 选中设备 → 连接 → 显示投屏状态 +- **设置页**: 分辨率/帧率/延迟模式/协议选择 +- **状态栏**: 连接状态、FPS、延迟、码率 + +### Rust 依赖 + +```toml +[dependencies] +tauri = "2" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +mdns-sd = "0.12" +tracing = "0.1" +anyhow = "1" + +# 屏幕采集 (平台相关) +[target.'cfg(target_os = "windows")'.dependencies] +captrs = "0.3" + +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.24" + +[target.'cfg(target_os = "linux")'.dependencies] +# PipeWire / x11 + +# 编码 +ffmpeg-next = "7" +``` + +--- + +## 实现路线 + +### Phase 1: ADP MVP (当前) +1. Android: `AirDisplayProtocol.kt` — ADP 协议处理器 +2. Android: `MdnsService.kt` — mDNS 注册 +3. Android: 改造 `WiFiDirectManager` — WFD IE 失败不阻塞 +4. Android: `ProtocolManager.kt` — 协议选择 +5. Desktop: 创建 `airdisplay-client` 仓库 + Rust/Tauri 脚手架 +6. Desktop: ADP 协议客户端 + mDNS 发现 +7. Desktop: 屏幕采集 + x264 软编 +8. Desktop: Tauri UI +9. ✅ 端到端第一次投屏 + +### Phase 2: 优化 +- 硬件编码支持 (NVENC/VideoToolbox/QSV) +- 延迟调优参数 +- UIBC 触摸回传 +- 分辨率/帧率自适应 + +### Phase 3: AirPlay +- UxPlay 移植到 Android NDK +- Mac 原生投屏 +- 音频支持 + +--- + +*三协议并行,不受系统权限限制,覆盖全部桌面端。* diff --git a/app/src/main/java/com/qwenpaw/miracast/MiracastService.kt b/app/src/main/java/com/qwenpaw/miracast/MiracastService.kt index f0f0f75..d8e53bb 100644 --- a/app/src/main/java/com/qwenpaw/miracast/MiracastService.kt +++ b/app/src/main/java/com/qwenpaw/miracast/MiracastService.kt @@ -14,6 +14,9 @@ import androidx.core.app.NotificationCompat import com.qwenpaw.miracast.media.MediaRenderer import com.qwenpaw.miracast.media.RtpReceiver import com.qwenpaw.miracast.media.TsDemuxer +import com.qwenpaw.miracast.protocol.AirDisplayProtocol +import com.qwenpaw.miracast.protocol.MdnsService +import com.qwenpaw.miracast.protocol.ProtocolManager import com.qwenpaw.miracast.rtsp.RtspServer import com.qwenpaw.miracast.uibc.UibcServer import com.qwenpaw.miracast.wifidirect.WiFiDirectManager @@ -63,7 +66,7 @@ class MiracastService : Service() { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - // 核心模块 + // === 三协议架构核心模块 === private lateinit var deviceNameManager: DeviceNameManager private lateinit var resolutionManager: ResolutionManager private lateinit var latencyManager: LatencyManager @@ -74,12 +77,20 @@ class MiracastService : Service() { private lateinit var mediaRenderer: MediaRenderer private lateinit var uibcServer: UibcServer + // === ADP 私有协议模块 === + private lateinit var protocolManager: ProtocolManager + private lateinit var airDisplayProtocol: AirDisplayProtocol + private lateinit var mdnsService: MdnsService + + // 当前激活的协议 + private var activeProtocol: ProtocolManager.Protocol = ProtocolManager.Protocol.NONE + // 状态 private var currentState = ServiceState.IDLE private var stateListener: ServiceStateListener? = null private var currentSurface: SurfaceView? = null - // 连接信息 + // Miracast 连接信息 private var remoteIp: String? = null private var remoteRtpPort = 0 private var serverRtpPort = 19002 @@ -142,7 +153,35 @@ class MiracastService : Service() { // Wi-Fi Direct 管理器 wifiDirectManager = WiFiDirectManager(this, deviceNameManager) - // 媒体渲染器 + // ===== 协议选择管理 ===== + + // 协议管理器(自动选择/手动切换) + protocolManager = ProtocolManager(wifiDirectManager) + + // WFD IE 状态影响协议选择 + wifiDirectManager.setWfdStatusListener(object : WiFiDirectManager.WfdStatusListener { + override fun onWfdStatusChanged(status: WiFiDirectManager.WfdStatus) { + Log.i(TAG, "WFD IE 状态变更: $status") + protocolManager.onWfdStatusChanged(status) + + when (status) { + WiFiDirectManager.WfdStatus.SUCCESS -> { + Log.i(TAG, "WFD IE 可用,Miracast 模式就绪") + updateNotification("Miracast 已就绪") + } + WiFiDirectManager.WfdStatus.FAILED -> { + Log.i(TAG, "WFD IE 不可用,自动降级到 ADP 协议") + // ADP 已提前启动,客户端可通过 ADP 连接 + updateNotification("ADP 模式已就绪") + } + WiFiDirectManager.WfdStatus.NOT_ATTEMPTED -> { + Log.i(TAG, "WFD IE 未尝试(可能 API 版本不足),使用 ADP 协议") + } + } + } + }) + + // ===== 媒体渲染器(需在 ADP 协议之前初始化) ===== mediaRenderer = MediaRenderer() mediaRenderer.setListener(object : MediaRenderer.RendererListener { override fun onStateChanged(state: MediaRenderer.State) { @@ -164,6 +203,76 @@ class MiracastService : Service() { } }) + // ADP 协议处理器(TCP 7935,依赖 mediaRenderer) + airDisplayProtocol = AirDisplayProtocol(mediaRenderer) + airDisplayProtocol.setListener(object : AirDisplayProtocol.ProtocolListener { + override fun onStateChanged(state: AirDisplayProtocol.State, message: String) { + Log.i(TAG, "ADP 状态: $state — $message") + protocolManager.setAdpServerRunning(state == AirDisplayProtocol.State.CONNECTED) + } + + override fun onClientConnected(clientIp: String) { + Log.i(TAG, "ADP 客户端已连接: $clientIp") + protocolManager.onClientConnected(ProtocolManager.Protocol.ADP, clientIp) + updateState(ServiceState.STREAMING, "ADP 投屏中") + protocolManager.setAdpServerRunning(true) + } + + override fun onClientDisconnected(reason: String) { + Log.i(TAG, "ADP 客户端断开: $reason") + protocolManager.onClientDisconnected(reason) + if (currentState == ServiceState.STREAMING) { + updateState(ServiceState.WAITING_FOR_CONNECTION, "等待连接…") + } + protocolManager.setAdpServerRunning(false) + } + + override fun onUibcEvent(eventJson: String) { + // ADP 协议中 UIBC 事件从客户端发来,转发到 UibcServer + Log.d(TAG, "ADP UIBC 事件: $eventJson") + } + + override fun onResolutionChanged(width: Int, height: Int, fps: Int) { + Log.i(TAG, "ADP 分辨率变更: ${width}x$height @ ${fps}fps") + stateListener?.onConnectionInfo(null, "${width}x$height", "$fps") + } + + override fun onError(error: String) { + Log.e(TAG, "ADP 错误: $error") + stateListener?.onError(error) + } + }) + + // mDNS 服务注册(供 LAN 发现) + mdnsService = MdnsService(this) + mdnsService.deviceName = deviceNameManager.getDeviceName() + mdnsService.setListener(object : MdnsService.MdnsListener { + override fun onServiceRegistered(serviceName: String) { + Log.i(TAG, "mDNS 已注册: $serviceName") + } + + override fun onServiceUnregistered() { + Log.d(TAG, "mDNS 已注销") + } + + override fun onError(error: String) { + Log.w(TAG, "mDNS 错误: $error") + } + }) + + // ===== 协议选择回调 ===== + protocolManager.setListener(object : ProtocolManager.ProtocolListener { + override fun onProtocolChanged(protocol: ProtocolManager.Protocol, reason: String) { + activeProtocol = protocol + Log.i(TAG, "协议已切换: $protocol ($reason)") + updateNotification("协议: $protocol") + } + + override fun onStateChanged(state: ProtocolManager.ProtocolState) { + Log.d(TAG, "协议状态: active=${state.activeProtocol}, mode=${state.selectionMode}") + } + }) + // RTSP 服务器(传递设备名用于 Server 头) val deviceName = deviceNameManager.getDeviceName() rtspServer = RtspServer(object : RtspServer.RtspServerListener { @@ -293,15 +402,22 @@ class MiracastService : Service() { } } - // ========== 启动 / 停止 Miracast ========== + // ========== 启动 / 停止 服务 ========== + /** + * 启动三协议并行服务 + * + * - Miracast (RTSP 7236): 依赖 WFD IE,PC 原生发现 + * - ADP (TCP 7935): 全场景可用,P2P/LAN 均支持 + * - AirPlay (预留): Mac 原生投屏 + */ private fun startMiracast() { - Log.i(TAG, ">>> 启动 Miracast 接收器") + Log.i(TAG, ">>> 启动 AirDisplay 服务 (三协议)") updateState(ServiceState.INITIALIZING, "正在初始化…") scope.launch { try { - // 1. 初始化 Wi-Fi Direct,创建 Group + // 1. 初始化 Wi-Fi Direct,创建 P2P Group updateState(ServiceState.INITIALIZING, "启动 Wi-Fi Direct…") wifiDirectManager.initialize() @@ -313,33 +429,64 @@ class MiracastService : Service() { mediaRenderer.setSurface(surfaceView) } - // 3. 启动 RTSP 服务器(先启动,等待 Wi-Fi Direct 连接) - updateState(ServiceState.WAITING_FOR_CONNECTION, "RTSP 服务器已就绪") + // 3. 启动 ADP TCP 服务器(全场景通用) + updateState(ServiceState.INITIALIZING, "启动 ADP 协议…") + val adpStarted = airDisplayProtocol.start(AirDisplayProtocol.DEFAULT_PORT) + if (adpStarted) { + Log.i(TAG, "ADP 服务器已启动 (端口 ${AirDisplayProtocol.DEFAULT_PORT})") + } else { + Log.w(TAG, "ADP 服务器启动失败,继续使用其他协议") + } + + // 4. 注册 mDNS 服务(供 LAN 发现) + mdnsService.register() + Log.i(TAG, "mDNS 已注册 (_airdisplay._tcp:${MdnsService.SERVICE_PORT})") + + // 5. 启动 RTSP 服务器(Miracast) + updateState(ServiceState.WAITING_FOR_CONNECTION, "服务已就绪") rtspServer.start() - // 4. 启动设备发现(让 Windows 可以找到我们) + // 6. 启动设备发现(让 Windows 可以通过 WFD IE 发现我们) wifiDirectManager.startDiscovery() - Log.i(TAG, "Miracast 接收器已就绪,等待 Windows 连接") + Log.i(TAG, """ + ===== AirDisplay 三协议已就绪 ===== + Miracast (RTSP): port 7236 + ADP (TCP): port ${AirDisplayProtocol.DEFAULT_PORT} + mDNS: _airdisplay._tcp + AirPlay: 待集成 + ================================== + """.trimIndent()) updateState(ServiceState.WAITING_FOR_CONNECTION, "等待连接…") - // 定期检查连接状态 - monitorWfdConnection() + // 7. 监控连接状态 + monitorConnections() } catch (e: Exception) { - Log.e(TAG, "启动 Miracast 失败", e) + Log.e(TAG, "启动服务失败", e) updateState(ServiceState.ERROR, "启动失败: ${e.message}") } } } + /** + * 停止所有协议服务 + */ private fun stopMiracast() { - Log.i(TAG, ">>> 停止 Miracast 接收器") + Log.i(TAG, ">>> 停止 AirDisplay 服务") scope.launch { + // 停止媒体流 stopMediaStreaming() + + // 停止所有协议服务 + airDisplayProtocol.stop() rtspServer.stop() + mdnsService.unregister() + + // 销毁 Wi-Fi Direct wifiDirectManager.destroy() + updateState(ServiceState.STOPPED, "已停止") } } @@ -380,9 +527,13 @@ class MiracastService : Service() { // ========== 连接监控 ========== /** - * 监控 Wi-Fi Direct 连接状态 + * 多协议连接监控 + * + * 同时监控 Wi-Fi Direct (Miracast) 和 ADP (TCP) 连接状态。 + * 任一协议建立连接后即视为投屏开始。 */ - private suspend fun monitorWfdConnection() { + private suspend fun monitorConnections() { + // 监控 Wi-Fi Direct 连接 (Miracast 路径) var lastConnected = false wifiDirectManager.connectionState.collect { conn -> @@ -392,7 +543,12 @@ class MiracastService : Service() { conn.clientDeviceName, null, null ) - updateState(ServiceState.HANDSHAKING, "Windows 已连接,正在握手…") + if (activeProtocol == ProtocolManager.Protocol.NONE) { + protocolManager.onClientConnected( + ProtocolManager.Protocol.MIRACAST, + conn.clientAddress ?: "unknown" + ) + } // 打印 SSID 和密码(调试用) Log.i(TAG, "P2P SSID: ${conn.groupNetworkName}") @@ -400,7 +556,12 @@ class MiracastService : Service() { } else if (!conn.isConnected && lastConnected) { Log.i(TAG, "Wi-Fi Direct 客户端已断开") - updateState(ServiceState.WAITING_FOR_CONNECTION, "等待连接…") + if (activeProtocol == ProtocolManager.Protocol.MIRACAST) { + protocolManager.onClientDisconnected("P2P 断开") + } + if (currentState == ServiceState.STREAMING) { + updateState(ServiceState.WAITING_FOR_CONNECTION, "等待连接…") + } } lastConnected = conn.isConnected } @@ -474,6 +635,21 @@ class MiracastService : Service() { */ fun getCurrentState(): ServiceState = currentState + /** + * 获取当前激活的协议 + */ + fun getActiveProtocol(): ProtocolManager.Protocol = activeProtocol + + /** + * 获取协议管理器(供 UI 使用) + */ + fun getProtocolManager(): ProtocolManager = protocolManager + + /** + * 获取 ADP 协议处理器 + */ + fun getAirDisplayProtocol(): AirDisplayProtocol = airDisplayProtocol + /** * 获取 P2P 连接信息 */ @@ -483,4 +659,16 @@ class MiracastService : Service() { wifiDirectManager.getGroupPassphrase() ) } + + /** + * 更新通知栏消息 + */ + private fun updateNotification(message: String) { + try { + val notification = buildNotification(message) + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, notification) + } catch (_: Exception) { + } + } } diff --git a/app/src/main/java/com/qwenpaw/miracast/protocol/AirDisplayProtocol.kt b/app/src/main/java/com/qwenpaw/miracast/protocol/AirDisplayProtocol.kt new file mode 100644 index 0000000..77ebce1 --- /dev/null +++ b/app/src/main/java/com/qwenpaw/miracast/protocol/AirDisplayProtocol.kt @@ -0,0 +1,755 @@ +package com.qwenpaw.miracast.protocol + +import android.util.Log +import com.qwenpaw.miracast.media.MediaRenderer +import kotlinx.coroutines.* +import org.json.JSONObject +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * AirDisplay Protocol (ADP) v1 — 自定义轻量屏幕投屏协议 + * + * == 设计目标 == + * 低延迟、简单可靠、跨平台。替代 Miracast RTSP/RTP/TS 栈, + * 在 WFD IE 不可用或同局域网场景下工作。 + * + * == 帧格式 (8 字节头部) == + * ``` + * ┌──────────────────────────────────────────────────┐ + * │ Type(1) │ Reserved(1) │ Length(2) │ Timestamp(4) │ + * ├──────────────────────────────────────────────────┤ + * │ Payload (变长) │ + * └──────────────────────────────────────────────────┘ + * ``` + * + * == Type 字段 == + * - 0x01 = Capability 协商 (JSON) + * - 0x02 = H.264 NAL 单元 (Annex B) + * - 0x03 = H.265 NAL 单元 (保留) + * - 0x04 = 音频帧 (AAC/Opus) + * - 0x05 = UIBC 事件 (JSON) + * - 0x06 = Keepalive + * - 0x07 = 分辨率/参数变更 (JSON) + * + * == 连接流程 == + * 1. Client 连接 TCP port 7935 + * 2. Server → Client: ADP/1.0 200 OK + Capabilities JSON + * 3. Client → Server: ADP/1.0 HANDSHAKE + Settings JSON + * 4. Server → Client: ADP/1.0 READY + * 5. 双向数据流 (视频帧 / UIBC 事件 / Keepalive) + */ +class AirDisplayProtocol( + private val mediaRenderer: MediaRenderer +) { + + companion object { + private const val TAG = "AirDisplayProtocol" + + /** ADP 默认端口 */ + const val DEFAULT_PORT = 7935 + + /** 最大帧体大小 (16MB,防止恶意大包) */ + private const val MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 + + /** Keepalive 超时秒数 */ + private const val KEEPALIVE_TIMEOUT_SEC = 10 + + /** 帧类型常量 */ + const val FRAME_TYPE_CAPABILITY = 0x01 + const val FRAME_TYPE_H264_NAL = 0x02 + const val FRAME_TYPE_H265_NAL = 0x03 + const val FRAME_TYPE_AUDIO = 0x04 + const val FRAME_TYPE_UIBC = 0x05 + const val FRAME_TYPE_KEEPALIVE = 0x06 + const val FRAME_TYPE_RESOLUTION_CHANGE = 0x07 + + /** 支持的编码器和能力 */ + private const val CAP_MAX_WIDTH = 1920 + private const val CAP_MAX_HEIGHT = 1080 + private const val CAP_CODECS = """["h264"]""" + private const val CAP_FEATURES = """["uibc","audio"]""" + } + + // ========== 状态定义 ========== + + /** ADP 协议会话状态 */ + enum class State { + IDLE, + LISTENING, + HANDSHAKING, + CONNECTED, + STOPPED, + ERROR + } + + /** ADP 会话回调 */ + interface ProtocolListener { + fun onStateChanged(state: State, message: String) + fun onClientConnected(clientIp: String) + fun onClientDisconnected(reason: String) + fun onUibcEvent(eventJson: String) + fun onResolutionChanged(width: Int, height: Int, fps: Int) + fun onError(error: String) + } + + override fun toString(): String { + return "AirDisplayProtocol(state=$currentState, clientIp=$clientIp)" + } + + // ========== 内部变量 ========== + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // 网络 + private var serverSocket: ServerSocket? = null + private var clientSocket: Socket? = null + private var inputStream: InputStream? = null + private var outputStream: OutputStream? = null + + // 状态 + @Volatile + private var currentState = State.IDLE + private var listener: ProtocolListener? = null + private val stateLock = Any() + + // 客户端信息 + @Volatile + private var clientIp: String = "" + + // 接收统计 + private var totalFrames = AtomicLong(0) + private var totalBytes = AtomicLong(0) + private var lastKeepaliveTime = 0L + + // 解码器配置(从 H.264 SPS/PPS 提取) + private var videoWidth = 0 + private var videoHeight = 0 + private var configuredCodec = false + private var csd0: ByteArray? = null // SPS + private var csd1: ByteArray? = null // PPS + + // 服务器启动等待通知 + private val serverReady = CompletableDeferred() + + // ========== 公共 API ========== + + /** + * 设置会话监听器 + */ + fun setListener(listener: ProtocolListener) { + this.listener = listener + } + + /** + * 获取当前状态 + */ + fun getState(): State = currentState + + /** + * 获取客户端 IP + */ + fun getClientIp(): String = clientIp + + /** + * 获取接收统计 + */ + fun getStats(): Triple { + return Triple(totalFrames.get(), totalBytes.get(), 0L) + } + + /** + * 启动 ADP 服务器,绑定到指定端口 + * + * @return true 如果服务器启动成功 + */ + suspend fun start(port: Int = DEFAULT_PORT): Boolean { + synchronized(stateLock) { + if (currentState != State.IDLE && currentState != State.STOPPED) { + Log.w(TAG, "ADP 已在运行 ($currentState),忽略重复启动") + return false + } + currentState = State.LISTENING + } + + return scope.async { + try { + serverSocket = ServerSocket(port) + serverSocket?.soTimeout = 0 // 阻塞模式 + Log.i(TAG, "ADP 服务已启动,监听端口 $port") + + updateState(State.LISTENING, "ADP 服务已启动,等待客户端连接") + + // 通知外部 + serverReady.complete(true) + + // 进入接受循环 + acceptLoop() + } catch (e: Exception) { + Log.e(TAG, "ADP 服务启动失败", e) + serverReady.complete(false) + updateState(State.ERROR, "ADP 服务启动失败: ${e.message}") + false + } + }.await() + } + + /** + * 等待服务器就绪 + */ + suspend fun waitForReady(timeoutMs: Long = 5000): Boolean { + return withTimeoutOrNull(timeoutMs) { + serverReady.await() + } ?: false + } + + /** + * 向客户端发送 UIBC 事件 + * @param eventJson UIBC 事件 JSON + */ + fun sendUibcEvent(eventJson: String) { + if (currentState != State.CONNECTED) return + val data = eventJson.toByteArray(Charsets.UTF_8) + sendFrame(FRAME_TYPE_UIBC, data, System.nanoTime() / 1000) + } + + /** + * 通知客户端分辨率变更 + */ + fun sendResolutionChanged(width: Int, height: Int, fps: Int) { + if (currentState != State.CONNECTED) return + val payload = JSONObject().apply { + put("width", width) + put("height", height) + put("fps", fps) + } + val data = payload.toString().toByteArray(Charsets.UTF_8) + sendFrame(FRAME_TYPE_RESOLUTION_CHANGE, data, System.nanoTime() / 1000) + } + + /** + * 停止 ADP 服务器并断开连接 + */ + fun stop() { + Log.i(TAG, "=== ADP 停止 ===") + updateState(State.STOPPED, "ADP 已停止") + cleanup() + scope.cancel() + } + + // ========== 网络核心 ========== + + /** + * 客户端接受循环 + */ + private suspend fun acceptLoop() { + var restartCount = 0 + + while (currentState == State.LISTENING || currentState == State.CONNECTED) { + try { + val ss = serverSocket ?: break + + // 等待客户端连接 (阻塞) + val client = ss.accept() + clientIp = client.inetAddress.hostAddress ?: "unknown" + Log.i(TAG, ">>> 客户端已连接: $clientIp") + + // 连接后关闭 ServerSocket (ADP 是单连接协议) + ss.close() + serverSocket = null + + clientSocket = client + inputStream = client.getInputStream() + outputStream = client.getOutputStream() + + // 开始握手 + val handshakeOk = performHandshake(client) + if (!handshakeOk) { + Log.w(TAG, "握手失败,断开连接") + client.close() + continue + } + + // 进入数据接收循环 + dataLoop(client) + + } catch (e: java.net.SocketException) { + if (currentState == State.STOPPED) break + Log.w(TAG, "Socket 异常,重新监听从: $restartCount", e) + // 非正常停止则尝试重启 + restartCount++ + if (restartCount < 3) { + delay(1000) + restartServer() + } else { + updateState(State.ERROR, "Socket 连续异常,停止重试") + break + } + } catch (e: Exception) { + if (currentState == State.STOPPED) break + Log.e(TAG, "接受连接异常", e) + delay(1000) + } + } + } + + /** + * 握手阶段 + * + * Server → Client: capability frame + * Client → Server: handshake frame + * Server → Client: ready frame + */ + private suspend fun performHandshake(client: Socket): Boolean { + updateState(State.HANDSHAKING, "正在握手…") + + return withContext(Dispatchers.IO) { + try { + client.soTimeout = 10000 // 10s 握手超时 + + // 1. 发送能力信息 + val capabilities = JSONObject().apply { + put("max_width", CAP_MAX_WIDTH) + put("max_height", CAP_MAX_HEIGHT) + put("codecs", CAP_CODECS) + put("features", CAP_FEATURES) + put("device_name", android.os.Build.MODEL) + put("protocol_version", "1.0") + } + val capData = capabilities.toString().toByteArray(Charsets.UTF_8) + sendFrame(FRAME_TYPE_CAPABILITY, capData, 0) + Log.i(TAG, "已发送 Capabilities: $capabilities") + + // 2. 读取客户端握手 + val header = readExact(8) ?: return@withContext false + val type = header[0].toInt() and 0xFF + val bodyLen = readU16(header, 2) + + if (type != FRAME_TYPE_CAPABILITY) { + Log.e(TAG, "期望客户端发送 Capability 帧,收到 type=$type") + return@withContext false + } + + if (bodyLen <= 0 || bodyLen > MAX_PAYLOAD_SIZE) { + Log.e(TAG, "无效的握手体长度: $bodyLen") + return@withContext false + } + + val body = readExact(bodyLen) ?: return@withContext false + val settings = String(body, Charsets.UTF_8) + Log.i(TAG, "收到客户端设置: $settings") + + // 解析客户端设置 + try { + val json = JSONObject(settings) + val width = json.optInt("width", 1920) + val height = json.optInt("height", 1080) + val fps = json.optInt("fps", 30) + val codec = json.optString("codec", "h264") + + videoWidth = width + videoHeight = height + + // 更新 MediaRenderer 流信息 + val mime = when (codec.lowercase()) { + "h264", "avc" -> "video/avc" + "h265", "hevc" -> "video/hevc" + else -> "video/avc" + } + mediaRenderer.updateStreamInfo(mime, "", width, height) + } catch (e: Exception) { + Log.w(TAG, "解析客户端设置失败,使用默认值", e) + videoWidth = 1920 + videoHeight = 1080 + } + + // 3. 发送 READY + val readyJson = JSONObject().apply { + put("status", "ready") + put("message", "AirDisplay protocol ready") + } + val readyData = readyJson.toString().toByteArray(Charsets.UTF_8) + sendFrame(FRAME_TYPE_CAPABILITY, readyData, 0) + Log.i(TAG, "已发送 READY") + + // 恢复超时设置(数据阶段较长时间不活动才超时) + client.soTimeout = KEEPALIVE_TIMEOUT_SEC * 1000 + + updateState(State.CONNECTED, "ADP 连接已建立") + listener?.onClientConnected(clientIp) + return@withContext true + + } catch (e: Exception) { + Log.e(TAG, "握手失败", e) + updateState(State.ERROR, "握手失败: ${e.message}") + return@withContext false + } + } + } + + /** + * 数据接收循环 + */ + private suspend fun dataLoop(client: Socket) { + Log.i(TAG, "=== 进入数据接收循环 ===") + lastKeepaliveTime = System.nanoTime() + + withContext(Dispatchers.IO) { + try { + while (currentState == State.CONNECTED && client.isConnected && !client.isClosed) { + + // 读取 8 字节帧头 + val header = readExact(8) ?: break + + val type = header[0].toInt() and 0xFF + val bodyLen = readU16(header, 2) + val timestampUs = readU32(header, 4) + + // 校验帧体长度 + if (bodyLen < 0 || bodyLen > MAX_PAYLOAD_SIZE) { + Log.w(TAG, "无效帧长度: $bodyLen,断开连接") + break + } + + // 读取帧体 + val body = if (bodyLen > 0) { + readExact(bodyLen) ?: break + } else { + ByteArray(0) + } + + totalFrames.incrementAndGet() + totalBytes.addAndGet(bodyLen.toLong()) + + // 根据类型处理 + when (type) { + FRAME_TYPE_H264_NAL -> handleH264Nal(body, timestampUs) + FRAME_TYPE_H265_NAL -> handleH265Nal(body, timestampUs) + FRAME_TYPE_AUDIO -> handleAudio(body, timestampUs) + FRAME_TYPE_UIBC -> handleUibc(body) + FRAME_TYPE_KEEPALIVE -> handleKeepalive() + FRAME_TYPE_RESOLUTION_CHANGE -> handleResolutionChange(body) + FRAME_TYPE_CAPABILITY -> { + // 数据阶段的 capability 帧(忽略或重新协商) + Log.d(TAG, "收到数据阶段 Capability 帧: ${String(body, Charsets.UTF_8)}") + } + else -> { + Log.w(TAG, "未知帧类型: $type,长度: $bodyLen") + } + } + } + } catch (e: SocketTimeoutException) { + // Keepalive 超时检查 + val now = System.nanoTime() + val elapsed = (now - lastKeepaliveTime) / 1_000_000_000 + if (elapsed > KEEPALIVE_TIMEOUT_SEC * 2) { + Log.w(TAG, "Keepalive 超时 ($elapsed 秒),断开连接") + } + } catch (e: Exception) { + if (currentState == State.CONNECTED) { + Log.e(TAG, "数据循环异常", e) + } + } finally { + Log.i(TAG, "=== 数据接收循环结束 ===") + cleanup() + listener?.onClientDisconnected("连接断开") + // 如果未停止,重新监听 + if (currentState == State.CONNECTED) { + currentState = State.LISTENING + updateState(State.LISTENING, "等待客户端重连") + scope.launch { + restartServer() + acceptLoop() + } + } + } + } + } + + // ========== 帧处理 ========== + + /** + * 处理 H.264 NAL 单元 + * + * 期望 body 包含完整的 Annex B NAL 单元 + * (起始码 0x00 0x00 0x00 0x01 + NAL 数据) + */ + private fun handleH264Nal(body: ByteArray, timestampUs: Long) { + if (body.isEmpty()) return + + // 检查是否为关键帧,提取 SPS/PPS + if (body.size >= 4) { + val nalType = extractNalType(body) + when (nalType) { + 7 -> { // SPS + csd0 = body + parseSpsResolution(body) + } + 8 -> { // PPS + csd1 = body + // 收到 SPS+PPS 后配置解码器 + if (csd0 != null && csd1 != null && !configuredCodec) { + configuredCodec = true + } + } + } + } + + // 将 NAL 数据传递给 MediaRenderer + // 确保数据包含 Annex B 起始码 + mediaRenderer.onVideoData( + data = body, + offset = 0, + length = body.size, + isKeyFrame = isKeyFrame(body), + ptsUs = timestampUs / 1000 // 转换为微秒 + ) + } + + /** + * 处理 H.265 NAL 单元(预留) + */ + private fun handleH265Nal(body: ByteArray, timestampUs: Long) { + // H.265 支持预留,先用 H.264 路径处理 + handleH264Nal(body, timestampUs) + } + + /** + * 处理音频帧(预留) + */ + private fun handleAudio(body: ByteArray, timestampUs: Long) { + // 音频支持预留 + Log.d(TAG, "收到音频帧: ${body.size} bytes, ts=$timestampUs") + } + + /** + * 处理 UIBC 事件 + */ + private fun handleUibc(body: ByteArray) { + if (body.isNotEmpty()) { + val eventJson = String(body, Charsets.UTF_8) + Log.d(TAG, "收到 UIBC 事件: $eventJson") + listener?.onUibcEvent(eventJson) + } + } + + /** + * 处理 Keepalive + */ + private fun handleKeepalive() { + lastKeepaliveTime = System.nanoTime() + // 回复一个空的 Keepalive 帧 + sendFrame(FRAME_TYPE_KEEPALIVE, ByteArray(0), System.nanoTime() / 1000) + } + + /** + * 处理分辨率变更通知 + */ + private fun handleResolutionChange(body: ByteArray) { + try { + val json = JSONObject(String(body, Charsets.UTF_8)) + val width = json.optInt("width", videoWidth) + val height = json.optInt("height", videoHeight) + val fps = json.optInt("fps", 30) + + videoWidth = width + videoHeight = height + mediaRenderer.updateStreamInfo("video/avc", "", width, height) + listener?.onResolutionChanged(width, height, fps) + + Log.i(TAG, "分辨率变更: ${width}x${height} @ ${fps}fps") + } catch (e: Exception) { + Log.w(TAG, "解析分辨率变更失败", e) + } + } + + // ========== I/O 基础 ========== + + /** + * 发送 ADP 帧 + */ + private fun sendFrame(type: Int, payload: ByteArray, timestampUs: Long) { + try { + val os = outputStream ?: return + val len = payload.size + + if (len > MAX_PAYLOAD_SIZE) { + Log.w(TAG, "帧体过大,跳过: $len bytes") + return + } + + // 构建 8 字节头部 + val header = ByteBuffer.allocate(8).apply { + order(ByteOrder.BIG_ENDIAN) + put(type.toByte()) + put(0x00.toByte()) // Reserved + putShort(len.toShort()) + putInt((timestampUs and 0xFFFFFFFFL).toInt()) + } + + synchronized(os) { + os.write(header.array()) + if (len > 0) { + os.write(payload) + } + os.flush() + } + } catch (e: Exception) { + Log.e(TAG, "发送帧失败 (type=$type)", e) + } + } + + /** + * 精确读取指定字节数,超时或 EOF 返回 null + */ + private fun readExact(size: Int): ByteArray? { + val stream = inputStream ?: return null + val buffer = ByteArray(size) + var offset = 0 + while (offset < size) { + val bytesRead = stream.read(buffer, offset, size - offset) + if (bytesRead == -1) return null // EOF + offset += bytesRead + } + return buffer + } + + /** + * 从字节数组读取 16 位无符号整数(大端序) + */ + private fun readU16(data: ByteArray, offset: Int): Int { + return ((data[offset].toInt() and 0xFF) shl 8) or + (data[offset + 1].toInt() and 0xFF) + } + + /** + * 从字节数组读取 32 位无符号整数(大端序) + */ + private fun readU32(data: ByteArray, offset: Int): Long { + return ((data[offset].toInt() and 0xFF).toLong() shl 24) or + ((data[offset + 1].toInt() and 0xFF).toLong() shl 16) or + ((data[offset + 2].toInt() and 0xFF).toLong() shl 8) or + (data[offset + 3].toInt() and 0xFF).toLong() + } + + // ========== H.264 辅助 ========== + + /** + * 提取 NAL 单元类型 (0 ~ 31) + * 非 H.264 数据返回 -1 + */ + private fun extractNalType(data: ByteArray): Int { + val offset = findStartCode(data, 0) + if (offset < 0 || offset >= data.size) return -1 + return data[offset].toInt() and 0x1F + } + + /** + * 判断是否为关键帧(IDR/SPS/PPS) + */ + private fun isKeyFrame(data: ByteArray): Boolean { + val nalType = extractNalType(data) + return nalType == 5 || nalType == 7 || nalType == 8 + } + + /** + * 查找 Annex B 起始码 (0x00 0x00 0x00 0x01 或 0x00 0x00 0x01) + * 返回 NAL 数据的起始位置(起始码后的第一个字节) + * 未找到返回 -1 + */ + private fun findStartCode(data: ByteArray, startOffset: Int): Int { + var i = startOffset + while (i + 2 < data.size) { + if (data[i] == 0x00.toByte() && data[i + 1] == 0x00.toByte()) { + if (i + 3 < data.size && data[i + 2] == 0x00.toByte() && data[i + 3] == 0x01.toByte()) { + return i + 4 + } + if (data[i + 2] == 0x01.toByte()) { + return i + 3 + } + } + i++ + } + return -1 + } + + /** + * 从 SPS 中解析分辨率(简化实现) + */ + private fun parseSpsResolution(sps: ByteArray) { + // 生产环境中应实现完整的 H.264 SPS 解析器 + // 当前让 MediaCodec 自动检测分辨率 + Log.d(TAG, "SPS 已收到,MediaCodec 将自动检测分辨率") + } + + // ========== 生命周期管理 ========== + + /** + * 重启服务器 + */ + private fun restartServer() { + try { + cleanup() + serverSocket = ServerSocket(DEFAULT_PORT) + serverSocket?.soTimeout = 0 + Log.i(TAG, "ADP 服务器已重启") + } catch (e: Exception) { + Log.e(TAG, "重启 ADP 服务器失败", e) + } + } + + /** + * 清理资源 + */ + private fun cleanup() { + try { + inputStream?.close() + } catch (_: Exception) {} + try { + outputStream?.close() + } catch (_: Exception) {} + try { + clientSocket?.close() + } catch (_: Exception) {} + try { + serverSocket?.close() + } catch (_: Exception) {} + + inputStream = null + outputStream = null + clientSocket = null + serverSocket = null + + // 重置配置状态(保留统计信息和分辨率信息以供重连使用) + configuredCodec = false + csd0 = null + csd1 = null + } + + private fun updateState(newState: State, message: String) { + synchronized(stateLock) { + if (currentState != newState) { + Log.i(TAG, "状态变更: $currentState -> $newState ($message)") + currentState = newState + } + } + listener?.onStateChanged(newState, message) + } + + // 内部统计用 AtomicLong + private class AtomicLong(initial: Long = 0L) { + private val value = java.util.concurrent.atomic.AtomicLong(initial) + fun get(): Long = value.get() + fun incrementAndGet(): Long = value.incrementAndGet() + fun addAndGet(delta: Long): Long = value.addAndGet(delta) + fun set(v: Long) = value.set(v) + } +} diff --git a/app/src/main/java/com/qwenpaw/miracast/protocol/MdnsService.kt b/app/src/main/java/com/qwenpaw/miracast/protocol/MdnsService.kt new file mode 100644 index 0000000..74a8661 --- /dev/null +++ b/app/src/main/java/com/qwenpaw/miracast/protocol/MdnsService.kt @@ -0,0 +1,258 @@ +package com.qwenpaw.miracast.protocol + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log + +/** + * mDNS 服务注册 —— 注册 _airdisplay._tcp 服务,实现同局域网设备发现 + * + * 使用 Android 内置 NsdManager API,无需额外依赖。 + * + * == 发现路径 == + * - P2P 模式:Wi‑Fi Direct P2P 服务发现 + ADP TCP 连接 + * - LAN 模式:mDNS (_airdisplay._tcp) → ADP TCP 连接 + * + * == TXT 记录 == + * - device: 设备型号 + * - protocol: 版本号 + * - capabilities: 设备能力 (codecs, resolution, features) + */ +class MdnsService(private val context: Context) { + + companion object { + private const val TAG = "MdnsService" + + /** ADP 协议服务类型 */ + const val SERVICE_TYPE = "_airdisplay._tcp" + + /** ADP 默认端口 */ + const val SERVICE_PORT = 7935 + + /** 重试最大次数 */ + private const val MAX_RETRIES = 3 + + /** 重试间隔 (ms) */ + private const val RETRY_DELAY_MS = 1000L + } + + /** mDNS 服务回调 */ + interface MdnsListener { + fun onServiceRegistered(serviceName: String) + fun onServiceUnregistered() + fun onError(error: String) + } + + // ========== 内部变量 ========== + + private var nsdManager: NsdManager = + context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager + + private var registrationListener: NsdManager.RegistrationListener? = null + private var isRegistered = false + private var listener: MdnsListener? = null + private var registeredServiceName: String = "" + + /** 设备名(由外部配置) */ + @Volatile + var deviceName: String = android.os.Build.MODEL + set(value) { + field = value + // 如果已注册,重新注册以更新名称 + if (isRegistered) { + Log.i(TAG, "设备名变更: $value,重新注册 mDNS") + unregister() + registerWithRetry() + } + } + + /** 设备能力(JSON 格式字符串) */ + @Volatile + var capabilities: String = "" + set(value) { + field = value + if (isRegistered) { + Log.i(TAG, "能力变更,重新注册 mDNS") + unregister() + registerWithRetry() + } + } + + override fun toString(): String { + val name = if (isRegistered) registeredServiceName else "not registered" + return "MdnsService($name, device=$deviceName)" + } + + // ========== 公共 API ========== + + /** + * 设置监听器 + */ + fun setListener(listener: MdnsListener) { + this.listener = listener + } + + /** + * 注册 mDNS 服务 + * @param port ADP 服务器端口,默认 7935 + */ + fun register(port: Int = SERVICE_PORT) { + if (isRegistered) { + Log.w(TAG, "mDNS 服务已注册,跳过") + return + } + + Log.i(TAG, "正在注册 mDNS 服务: $SERVICE_TYPE:$port") + + try { + val serviceInfo = NsdServiceInfo().apply { + serviceType = SERVICE_TYPE + serviceName = "AirDisplay - $deviceName" + port = port + + // 添加 TXT 记录 + val txtRecords = buildTxtRecords() + for ((key, value) in txtRecords) { + setAttribute(key, value) + } + } + + val listener = createRegistrationListener() + registrationListener = listener + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener) + + } catch (e: Exception) { + Log.e(TAG, "mDNS 注册失败", e) + listener?.onError("mDNS 注册失败: ${e.message}") + } + } + + /** + * 带重试的注册 + */ + fun registerWithRetry(port: Int = SERVICE_PORT) { + var attempts = 0 + var lastError: String? = null + + while (attempts < MAX_RETRIES && !isRegistered) { + attempts++ + try { + register(port) + // 如果注册成功(同步回调),等待一下让回调触发 + if (isRegistered) break + // 如果没立即成功,等待后重试 + Thread.sleep(RETRY_DELAY_MS) + } catch (e: Exception) { + lastError = e.message + Log.w(TAG, "mDNS 注册第 $attempts 次尝试失败", e) + if (attempts < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + } + } + } + + if (!isRegistered) { + Log.e(TAG, "mDNS 注册全部重试失败: $lastError") + listener?.onError("mDNS 注册失败 (已重试 $MAX_RETRIES 次): $lastError") + } + } + + /** + * 注销 mDNS 服务 + */ + fun unregister() { + if (!isRegistered) { + Log.d(TAG, "mDNS 服务未注册,无需注销") + return + } + + try { + registrationListener?.let { + nsdManager.unregisterService(it) + } + } catch (e: Exception) { + Log.w(TAG, "mDNS 注销异常", e) + } + + registrationListener = null + isRegistered = false + listener?.onServiceUnregistered() + Log.i(TAG, "mDNS 服务已注销") + } + + /** + * 是否已注册 + */ + fun isRegistered(): Boolean = isRegistered + + /** + * 获取注册的服务名 + */ + fun getRegisteredServiceName(): String = registeredServiceName + + // ========== 内部实现 ========== + + /** + * 构建 TXT 记录 + */ + private fun buildTxtRecords(): Map { + val records = mutableMapOf( + "device" to deviceName, + "protocol" to "airdisplay/1.0", + "platform" to "android", + "os" to "Android ${android.os.Build.VERSION.SDK_INT}" + ) + + // 如果有能力信息,添加到 TXT 记录 + if (capabilities.isNotBlank()) { + records["capabilities"] = capabilities + } + + // 添加分辨率信息 + records["max_width"] = "1920" + records["max_height"] = "1080" + + return records + } + + /** + * 创建注册监听器 + */ + private fun createRegistrationListener(): NsdManager.RegistrationListener { + return object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + // 注意:系统可能会修改服务名(如果重名会自动加上 "(2)") + registeredServiceName = serviceInfo.serviceName + isRegistered = true + Log.i(TAG, "mDNS 服务注册成功: $registeredServiceName") + listener?.onServiceRegistered(registeredServiceName) + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + val msg = "mDNS 注销失败: errorCode=$errorCode" + Log.w(TAG, msg) + isRegistered = false + listener?.onError(msg) + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + val errorMsg = when (errorCode) { + NsdManager.FAILURE_ALREADY_ACTIVE -> "服务已激活" + NsdManager.FAILURE_INTERNAL_ERROR -> "内部错误" + NsdManager.FAILURE_MAX_LIMIT -> "达到最大限制" + else -> "未知错误 ($errorCode)" + } + val msg = "mDNS 注册失败: $errorMsg" + Log.e(TAG, msg) + isRegistered = false + listener?.onError(msg) + } + + override fun onUnregistered(serviceInfo: NsdServiceInfo) { + Log.i(TAG, "mDNS 服务已注销: ${serviceInfo.serviceName}") + isRegistered = false + } + } + } +} diff --git a/app/src/main/java/com/qwenpaw/miracast/protocol/ProtocolManager.kt b/app/src/main/java/com/qwenpaw/miracast/protocol/ProtocolManager.kt new file mode 100644 index 0000000..588fa7c --- /dev/null +++ b/app/src/main/java/com/qwenpaw/miracast/protocol/ProtocolManager.kt @@ -0,0 +1,259 @@ +package com.qwenpaw.miracast.protocol + +import android.util.Log +import com.qwenpaw.miracast.wifidirect.WiFiDirectManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.json.JSONObject + +/** + * 协议选择管理器 —— 根据设备和环境自动/手动选择投屏协议 + * + * == 选择策略 == + * ``` + * ┌─────────────────────────────────────────────────┐ + * │ 用户手动选择 → 强制使用指定协议 │ + * ├─────────────────────────────────────────────────┤ + * │ 自动模式: │ + * │ Wi-Fi Direct + WFD IE 可用 → Miracast │ + * │ Wi-Fi Direct + WFD IE 失败 → ADP (P2P) │ + * │ 同局域网 → ADP (LAN) │ + * │ Mac 投屏请求 → AirPlay │ + * └─────────────────────────────────────────────────┘ + * ``` + */ +class ProtocolManager( + private val wifiDirectManager: WiFiDirectManager +) { + + companion object { + private const val TAG = "ProtocolManager" + + /** ADP TCP 端口 */ + const val ADP_PORT = 7935 + + /** RTSP (Miracast) TCP 端口 */ + const val MIRACAST_RTSP_PORT = 7236 + } + + // ========== 协议枚举 ========== + + /** 支持的投屏协议 */ + enum class Protocol(val description: String) { + MIRACAST("Miracast (Wi-Fi Display) — Windows 原生"), + ADP("AirDisplay Protocol — 自定义轻量协议"), + AIRPLAY("AirPlay — Mac/iOS 原生"), + NONE("无"); + } + + /** 协议选择模式 */ + enum class SelectionMode { + /** 自动选择(根据环境决定) */ + AUTO, + /** 强制使用 Miracast */ + FORCE_MIRACAST, + /** 强制使用 ADP */ + FORCE_ADP, + /** 强制使用 AirPlay */ + FORCE_AIRPLAY + } + + /** 协议状态 */ + data class ProtocolState( + /** 当前使用的协议 */ + val activeProtocol: Protocol = Protocol.NONE, + /** 选择模式 */ + val selectionMode: SelectionMode = SelectionMode.AUTO, + /** WFD IE 是否可用 */ + val wfdAvailable: Boolean = false, + /** 到客户端的连接是否已建立 */ + val isConnected: Boolean = false, + /** 客户端 IP 地址 */ + val clientIp: String = "", + /** ADP 服务器是否在运行 */ + val adpServerRunning: Boolean = false + ) + + /** 协议监听器 */ + interface ProtocolListener { + fun onProtocolChanged(protocol: Protocol, reason: String) + fun onStateChanged(state: ProtocolState) + } + + // ========== 内部变量 ========== + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _state = MutableStateFlow(ProtocolState()) + val state: StateFlow = _state.asStateFlow() + + private var listener: ProtocolListener? = null + private var userOverride: Protocol? = null + private var wfdStatus: WiFiDirectManager.WfdStatus = WiFiDirectManager.WfdStatus.NOT_ATTEMPTED + + override fun toString(): String { + val s = _state.value + return "ProtocolManager(${s.activeProtocol}, mode=${s.selectionMode})" + } + + // ========== 公共 API ========== + + /** + * 设置协议监听器 + */ + fun setListener(listener: ProtocolListener?) { + this.listener = listener + } + + /** + * 获取当前协议状态 + */ + fun getState(): ProtocolState = _state.value + + /** + * 获取当前激活的协议 + */ + fun getActiveProtocol(): Protocol = _state.value.activeProtocol + + /** + * 用户手动选择协议 + * + * @param protocol 指定协议,传 null 切换回自动模式 + */ + fun selectProtocol(protocol: Protocol?) { + userOverride = protocol + val mode = if (protocol != null) { + when (protocol) { + Protocol.MIRACAST -> SelectionMode.FORCE_MIRACAST + Protocol.ADP -> SelectionMode.FORCE_ADP + Protocol.AIRPLAY -> SelectionMode.FORCE_AIRPLAY + Protocol.NONE -> SelectionMode.AUTO + } + } else { + SelectionMode.AUTO + } + + _state.update { it.copy(selectionMode = mode) } + + if (protocol != null) { + Log.i(TAG, "用户手动选择: $protocol") + activateProtocol(protocol, "用户手动选择") + } else { + Log.i(TAG, "切换回自动选择模式") + autoSelect() + } + } + + /** + * 通知 WFD IE 状态变更(由 WiFiDirectManager 回调) + */ + fun onWfdStatusChanged(status: WiFiDirectManager.WfdStatus) { + wfdStatus = status + _state.update { it.copy(wfdAvailable = status == WiFiDirectManager.WfdStatus.SUCCESS) } + + Log.i(TAG, "WFD IE 状态已更新: $status") + + // 在自动模式下触发重新选择 + if (_state.value.selectionMode == SelectionMode.AUTO) { + autoSelect() + } + } + + /** + * 通知连接事件 + */ + fun onClientConnected(protocol: Protocol, clientIp: String) { + _state.update { + it.copy( + isConnected = true, + clientIp = clientIp, + activeProtocol = protocol + ) + } + listener?.onStateChanged(_state.value) + Log.i(TAG, "客户端已连接 (${protocol}): $clientIp") + } + + /** + * 通知断开事件 + */ + fun onClientDisconnected(reason: String) { + val wasConnected = _state.value.isConnected + _state.update { it.copy(isConnected = false, clientIp = "") } + + if (wasConnected) { + Log.i(TAG, "客户端已断开: $reason") + // 自动模式下等待重新连接 + if (_state.value.selectionMode == SelectionMode.AUTO) { + autoSelect() + } + } + listener?.onStateChanged(_state.value) + } + + /** + * 更新 ADP 服务器状态 + */ + fun setAdpServerRunning(running: Boolean) { + _state.update { it.copy(adpServerRunning = running) } + } + + /** + * 获取当前建议的协议(不触发切换) + */ + fun getSuggestedProtocol(): Protocol { + // 用户覆盖优先 + userOverride?.let { return it } + + // WFD IE 成功 → 首选 Miracast + if (wfdStatus == WiFiDirectManager.WfdStatus.SUCCESS) { + return Protocol.MIRACAST + } + + // WFD IE 失败或未尝试 → ADP + return Protocol.ADP + } + + // ========== 内部实现 ========== + + /** + * 自动选择协议 + */ + private fun autoSelect() { + if (_state.value.selectionMode != SelectionMode.AUTO) return + if (_state.value.isConnected) return // 已连接时不切换 + + val suggested = getSuggestedProtocol() + val currentActive = _state.value.activeProtocol + + if (suggested != currentActive) { + val reason = when (suggested) { + Protocol.MIRACAST -> "WFD IE 可用,使用原生 Miracast" + Protocol.ADP -> "WFD IE 不可用或受限,降级到 ADP 协议" + Protocol.AIRPLAY -> "AirPlay 协议" + Protocol.NONE -> "无可用协议" + } + activateProtocol(suggested, reason) + } + } + + /** + * 激活指定协议 + */ + private fun activateProtocol(protocol: Protocol, reason: String) { + if (protocol == Protocol.NONE) return + + Log.i(TAG, ">>> 协议切换: ${_state.value.activeProtocol} → $protocol (原因: $reason)") + _state.update { it.copy(activeProtocol = protocol) } + + listener?.onProtocolChanged(protocol, reason) + listener?.onStateChanged(_state.value) + } + + /** + * 销毁 + */ + fun destroy() { + scope.cancel() + } +} diff --git a/app/src/main/java/com/qwenpaw/miracast/wifidirect/WiFiDirectManager.kt b/app/src/main/java/com/qwenpaw/miracast/wifidirect/WiFiDirectManager.kt index 2a85f67..3d87d55 100644 --- a/app/src/main/java/com/qwenpaw/miracast/wifidirect/WiFiDirectManager.kt +++ b/app/src/main/java/com/qwenpaw/miracast/wifidirect/WiFiDirectManager.kt @@ -3,12 +3,9 @@ package com.qwenpaw.miracast.wifidirect import android.content.Context import android.net.MacAddress import android.net.wifi.WifiManager -import android.net.wifi.p2p.WifiP2pConfig -import android.net.wifi.p2p.WifiP2pDevice -import android.net.wifi.p2p.WifiP2pDeviceList -import android.net.wifi.p2p.WifiP2pGroup -import android.net.wifi.p2p.WifiP2pInfo -import android.net.wifi.p2p.WifiP2pManager +import android.net.wifi.p2p.* +import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceInfo +import android.net.wifi.p2p.nsd.WifiP2pServiceInfo import android.os.Build import android.os.Looper import android.text.format.Formatter @@ -37,6 +34,30 @@ class WiFiDirectManager( companion object { private const val TAG = "WiFiDirectManager" private const val GROUP_OWNER_INTENT = 15 // 最大优先级,确保成为 GO + + /** ADP 服务类型(P2P 服务发现用) */ + private const val ADP_SERVICE_TYPE = "_airdisplay._tcp" + + /** ADP 协议标识 */ + private const val ADP_SERVICE_NAME = "airdisplay" + + /** TXT 记录版本 */ + private const val ADP_TXT_VERSION = "1.0" + } + + /// WFD IE 设置结果 + enum class WfdStatus { + /** WFD IE 设置成功,可用原生 Miracast */ + SUCCESS, + /** WFD IE 设置失败,需要降级到 ADP */ + FAILED, + /** 未尝试设置(API 级别不支持等) */ + NOT_ATTEMPTED + } + + /// WFD 状态监听器 + interface WfdStatusListener { + fun onWfdStatusChanged(status: WfdStatus) } private val wifiManager: WifiManager = @@ -50,6 +71,13 @@ class WiFiDirectManager( private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + /// WFD IE 状态 + @Volatile + private var wfdStatus = WfdStatus.NOT_ATTEMPTED + + /// WFD 状态监听器 + private var wfdStatusListener: WfdStatusListener? = null + /// 连接状态数据类 data class ConnectionInfo( val isConnected: Boolean = false, @@ -77,6 +105,21 @@ class WiFiDirectManager( private var isInitialized = false private var discoveryActive = false + /// ADP P2P 服务是否已注册 + private var adpServiceRegistered = false + + /** + * 设置 WFD 状态监听器 + */ + fun setWfdStatusListener(listener: WfdStatusListener?) { + wfdStatusListener = listener + } + + /** + * 获取当前 WFD IE 状态 + */ + fun getWfdStatus(): WfdStatus = wfdStatus + // ========== 初始化与销毁 ========== /** @@ -111,6 +154,7 @@ class WiFiDirectManager( */ fun destroy() { Log.i(TAG, "销毁 Wi-Fi Direct") + unregisterP2pService() stopDiscovery() removeGroup() isInitialized = false @@ -169,6 +213,8 @@ class WiFiDirectManager( if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { Log.i(TAG, "WFD IE 需要 API 29+,当前版本 ${Build.VERSION.SDK_INT} 不支持,跳过") + wfdStatus = WfdStatus.NOT_ATTEMPTED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) return } @@ -179,6 +225,8 @@ class WiFiDirectManager( wfdInfo = wfdInfoClass.getDeclaredConstructor().newInstance() } catch (e: Exception) { Log.e(TAG, "✗ WifiP2pWfdInfo 类不可用: ${e.message}") + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) return } @@ -303,6 +351,8 @@ class WiFiDirectManager( object : WifiP2pManager.ActionListener { override fun onSuccess() { Log.i(TAG, "✓ WFD IE 设置成功!设备已注册为 Miracast 接收器") + wfdStatus = WfdStatus.SUCCESS + wfdStatusListener?.onWfdStatusChanged(wfdStatus) } override fun onFailure(reason: Int) { @@ -312,20 +362,116 @@ class WiFiDirectManager( 2 -> "BUSY" else -> "UNKNOWN($reason)" } - Log.w(TAG, "✗ WFD IE onFailure: reason=$r") + Log.w(TAG, "✗ WFD IE onFailure: reason=$r — 自动降级到 ADP 协议") + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) } } ) } catch (e: NoSuchMethodException) { Log.w(TAG, "✗ setWfdInfo 方法不存在(OEM 未实现此隐藏 API): ${e.message}") + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) } catch (e: InvocationTargetException) { Log.e(TAG, "✗ setWfdInfo 底层异常: ${e.cause?.javaClass?.name ?: "未知"}: ${e.cause?.message ?: "无消息"}") e.cause?.printStackTrace() + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) } catch (e: SecurityException) { Log.e(TAG, "✗ setWfdInfo 无权限: ${e.message}(需要系统权限,非系统应用可能被阻止)") + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) } catch (e: Exception) { Log.e(TAG, "✗ setWfdInfo 反射异常: ${e.javaClass.name}: ${e.message}") e.printStackTrace() + wfdStatus = WfdStatus.FAILED + wfdStatusListener?.onWfdStatusChanged(wfdStatus) + } + } + + // ========== P2P 服务发现 (ADP) ========== + + /** + * 注册 ADP 协议 P2P 服务发现。 + * + * 在 Wi‑Fi Direct 模式下,桌面客户端可以通过 P2P 服务发现 + * 检索到本设备支持 ADP 协议,无需 mDNS(P2P 模式下 mDNS 通常不可用)。 + * + * 注册的服务类型: _airdisplay._tcp + * TXT 记录中包含设备能力和连接信息。 + */ + private fun registerP2pServiceForAdp() { + if (adpServiceRegistered) return + + try { + // 构建 TXT 记录 + val txtRecords = mapOf( + "version" to ADP_TXT_VERSION, + "device" to (deviceNameManager?.getDeviceName() ?: android.os.Build.MODEL), + "protocol" to "airdisplay", + "port" to "7935", + "max_width" to "1920", + "max_height" to "1080", + "codecs" to "h264" + ) + + // 创建 DNS-SD 服务信息 + val serviceInfo = WifiP2pDnsSdServiceInfo.newInstance( + ADP_SERVICE_NAME, + ADP_SERVICE_TYPE, + txtRecords + ) + + // 注册服务 + p2pManager.addLocalService(p2pChannel, serviceInfo, + object : WifiP2pManager.ActionListener { + override fun onSuccess() { + adpServiceRegistered = true + Log.i(TAG, "ADP P2P 服务已注册: $ADP_SERVICE_NAME.$ADP_SERVICE_TYPE") + } + + override fun onFailure(reason: Int) { + val reasonStr = when (reason) { + WifiP2pManager.BUSY -> "BUSY" + WifiP2pManager.ERROR -> "ERROR" + WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" + else -> "UNKNOWN($reason)" + } + Log.w(TAG, "ADP P2P 服务注册失败: $reasonStr(桌面客户端需通过 mDNS 发现)") + } + } + ) + } catch (e: Exception) { + Log.w(TAG, "ADP P2P 服务注册异常: ${e.message}") + } + } + + /** + * 注销 ADP P2P 服务 + */ + private fun unregisterP2pService() { + if (!adpServiceRegistered) return + + try { + val serviceInfo = WifiP2pDnsSdServiceInfo.newInstance( + ADP_SERVICE_NAME, + ADP_SERVICE_TYPE, + emptyMap() + ) + p2pManager.removeLocalService(p2pChannel, serviceInfo, + object : WifiP2pManager.ActionListener { + override fun onSuccess() { + adpServiceRegistered = false + Log.i(TAG, "ADP P2P 服务已注销") + } + + override fun onFailure(reason: Int) { + Log.w(TAG, "ADP P2P 服务注销失败: $reason") + } + } + ) + } catch (e: Exception) { + Log.w(TAG, "ADP P2P 服务注销异常: ${e.message}") } } @@ -343,6 +489,8 @@ class WiFiDirectManager( Log.i(TAG, "P2P Group 创建成功,本设备为 Group Owner") // Group 创建后再设置一次 WFD IE,确保 IE 在 P2P 接口上生效 trySetWfdInfo() + // 注册 ADP P2P 服务发现(桌面客户端可通过 Wi-Fi Direct 发现本设备) + registerP2pServiceForAdp() // 稍等片刻让系统完成网络配置 scope.launch { delay(2000)