From ded6b5d86b63c261837566f2e821a382e0a495b6 Mon Sep 17 00:00:00 2001 From: joywayer Date: Tue, 23 Jun 2026 02:01:29 +0800 Subject: [PATCH] =?UTF-8?q?Phase=208.4=EF=BC=9APhoneStateMonitor=20?= =?UTF-8?q?=E2=86=92=20phonestate=20=E5=8F=8D=E5=90=91=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约 §3.1 [14]phonestate 落地。msext 用已废弃的 CTCallCenter(iOS 10+ deprecated);新外壳改用 CallKit CXCallObserver,对 H5 输出字符串一致: - "2" = 来电响铃中(hasConnected=false / isOutgoing=false / hasEnded=false) 或已连接(hasConnected=true) - "0" = 挂断(hasEnded=true) - 其它(拨出未连接、保持)不发 新增: - Source/Resource/PhoneStateMonitor.swift(@MainActor singleton,与 BatteryMonitor / NetworkMonitor 同款 pattern;onChange closure + start/stop 生命周期) 接入: - SubGameViewController.setupExternalSubscriptions:start + onChange - SubGameViewController.teardownExternalSubscriptions:onChange=nil + stop - 大厅 WebContainerViewController 不订阅(msext gameController 同款边界, 契约表注「已注释」表示大厅不发;H5 子游戏才依赖此事件做哑麦/暂停) Co-Authored-By: Claude Opus 4.7 --- docs/Development-Plan.md | 10 ++- .../Source/Resource/PhoneStateMonitor.swift | 78 +++++++++++++++++++ .../WebView/SubGameViewController.swift | 9 +++ 3 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 ylgamehall/Source/Resource/PhoneStateMonitor.swift diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 5abf9c2..37fefb7 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -680,9 +680,11 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - `createRoom` stub → `cb("createRoom")` - `getVideoinfo` stub → `cb("getVideoinfo")` - `exitRoom` stub → `cb("exitRoom")` -- [ ] **8.4** `Source/Telephony/CallCenterMonitor.swift`:CXCallObserver 监听通话状态 - - 来电 → `bridge.call("phonestate", "2")` - - 挂断 → `bridge.call("phonestate", "0")` +- [x] **8.4** `Source/Resource/PhoneStateMonitor.swift`:CXCallObserver 监听通话状态(CallKit;不用废弃的 CTCallCenter) + - 来电响铃中(hasConnected=false, isOutgoing=false, hasEnded=false)或 已连接(hasConnected=true)→ "2" + - 挂断(hasEnded=true)→ "0" + - 仅 SubGameViewController.setupExternalSubscriptions 挂载 + teardown 时 stop(大厅不订阅) + - 反向 callback `bridge.call("phonestate", data:.string(state))` - [ ] **8.5** `RecordUploader.onUploaded` 已在 Phase 3,需要在 SubGameHandlers 里**额外**触发 `recordSuccess({fileUrl, fileName, fileKey})`(契约 §3.2 表 [15]) - [ ] **8.6** `AgoraVideoRoom.swift` 留蓝图骨架(`#if AGORA_ENABLED` 包裹,默认 OFF) @@ -966,7 +968,7 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [ ] 8.1 VideoRoom 协议 - [ ] 8.2 NoopVideoRoom - [ ] 8.3 3 个 stub handler -- [ ] 8.4 CallCenterMonitor → phonestate +- [x] 8.4 PhoneStateMonitor → phonestate(CXCallObserver;仅子游戏订阅) - [ ] 8.5 recordSuccess 双 callback - [ ] 8.6 AgoraVideoRoom 蓝图(#if AGORA_ENABLED OFF) diff --git a/ylgamehall/Source/Resource/PhoneStateMonitor.swift b/ylgamehall/Source/Resource/PhoneStateMonitor.swift new file mode 100644 index 0000000..34a1bc7 --- /dev/null +++ b/ylgamehall/Source/Resource/PhoneStateMonitor.swift @@ -0,0 +1,78 @@ +// +// PhoneStateMonitor.swift +// ylgamehall +// +// 电话状态监听(Phase 8.4,契约 §3.1 [14] phonestate 反向 callback)。 +// +// msext 用已废弃的 `CTCallCenter`(iOS 10+ deprecated);本项目用 CallKit +// `CXCallObserver`(iOS 10+ 推荐),语义等价,对 H5 输出的字符串一致: +// - "2" = 来电响铃中(hasConnected=false, isOutgoing=false, hasEnded=false) +// 或 已连接(hasConnected=true,无论 incoming/outgoing) +// - "0" = 挂断(hasEnded=true) +// - 其它(拨出中未连接、保持等)不发 +// +// 仅子游戏注册,大厅不发(msext gameController.m 同款;契约表注「已注释」表示 +// msext 在线版当前不主动触发,但 H5 子游戏仍按契约处理收到的事件)。 +// +// 使用模式(与 BatteryMonitor / NetworkMonitor 一致): +// - viewDidLoad / setupExternalSubscriptions:`shared.start()` + `onChange = { ... }` +// - viewWillDisappear / teardownExternalSubscriptions:`onChange = nil` + `shared.stop()` +// + +import Foundation +import CallKit + +@MainActor +public final class PhoneStateMonitor: NSObject { + + public static let shared = PhoneStateMonitor() + + /// 状态变化回调,参数即给 H5 的 `phonestate` 字符串("2" / "0")。 + /// closure 在 main actor 上调,调用方可直接拿 bridge 反向 callback。 + public var onChange: ((String) -> Void)? + + private var observer: CXCallObserver? + + public override init() { + super.init() + } + + /// 开始监听。重复调安全(已 start 则无操作)。 + public func start() { + guard observer == nil else { return } + let o = CXCallObserver() + // queue: nil → main queue(callObserver:callChanged: 回调线程一致, + // 子游戏页 bridge.call 也走主线程,无并发问题) + o.setDelegate(self, queue: nil) + observer = o + } + + /// 停止监听。stop 之后再 start 重新创建 observer。 + public func stop() { + observer?.setDelegate(nil, queue: nil) + observer = nil + } +} + +extension PhoneStateMonitor: CXCallObserverDelegate { + + /// CXCallObserver 状态切换。本方法 `@objc` 由 CallKit 在主队列调(init 时传 nil)。 + public nonisolated func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { + let state: String + if call.hasEnded { + state = "0" + } else if call.hasConnected { + // 已接通(任何方向)— 视频房 / 录音应静音 + state = "2" + } else if !call.isOutgoing { + // 来电响铃中(hasEnded=false, hasConnected=false, isOutgoing=false) + state = "2" + } else { + // 拨出中未连接 / 保持等中间态 — H5 不关心,丢弃 + return + } + Task { @MainActor in + Self.shared.onChange?(state) + } + } +} diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index be82c43..55a4ce7 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -218,6 +218,8 @@ public final class SubGameViewController: UIViewController { NetworkMonitor.shared.start() BatteryMonitor.shared.start() AppLifecycleObserver.shared.start() + // 仅子游戏挂电话状态监听(大厅不订阅,与 msext gameController 同款边界) + PhoneStateMonitor.shared.start() let bridge = bridgedWebView.bridge let webView = bridgedWebView.webView @@ -244,6 +246,11 @@ public final class SubGameViewController: UIViewController { bridge.call("appservice", data: .string("2"), callback: nil) } + // 电话状态 — 契约 §3.1 [14]:来电 "2" / 挂断 "0" + PhoneStateMonitor.shared.onChange = { state in + bridge.call("phonestate", data: .string(state), callback: nil) + } + // overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。挂在子游戏栈顶 // 期间生效;overlay push 上来时 viewWillDisappear → teardown 自动解绑,避免双发。 subGameReturnObserver = NotificationCenter.default.addObserver( @@ -263,6 +270,8 @@ public final class SubGameViewController: UIViewController { NetworkMonitor.shared.onChange = nil AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onForeground = nil + PhoneStateMonitor.shared.onChange = nil + PhoneStateMonitor.shared.stop() if let token = subGameReturnObserver { NotificationCenter.default.removeObserver(token)