Files
youle_app_ios_v2/ylgamehall/Source/Bridge/Handlers/RemoteAudioHandler.swift
T
joywayer 7d89a9d3ee 3.E 修复:换用 iOS 17+ AVAudioApplication.requestRecordPermission 避免 EXC_BREAKPOINT
## 问题

第一次调用 prepareaudio 弹出系统权限对话框,用户点允许/拒绝都触发
Thread 10 EXC_BREAKPOINT 崩溃。

## 根因猜测

deprecated 的 AVAudioSession.requestRecordPermission(_:) 在
iOS 26.5 SDK 上内部桥接到 AVAudioApplication 时,与
withCheckedContinuation 的 Swift Concurrency 状态机交互异常。
具体表现:completion 在 Thread 10 触发 cont.resume(returning:),
runtime 检测到某种 actor / continuation 状态违规,触发 trap。

## 修复

新增 `requestMicrophonePermission()` 私有助手:

```swift
if #available(iOS 17.0, *) {
    return await AVAudioApplication.requestRecordPermission()  // 原生 async
} else {
    return await withCheckedContinuation { ... }              // 旧 SDK 兼容
}
```

iOS 17+ 走原生 async API,不经 Continuation 状态机,避开 corner case;
iOS 15.6 / 16 走 deprecated API 兼容路径。

## 验证

BuildProject 通过 0 错误。真机首次启动 → 按录音按钮 → 系统权限对话框
出现 → 点允许/拒绝 → 应无崩溃。
2026-06-24 02:05:25 +08:00

388 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RemoteAudioHandler.swift
// ylgamehall
//
// H5 → Native handlersprepareaudio / mediaTypeAudio
// 契约:docs/H5-Native-Contract.md §3.1 4][5 + §3.2 [7][8][9]
//
// ## prepareaudio 完整链路(Phase 3.E
//
// H5 调用 → 权限检查 →
// ├─ 拒绝:弹 Alert,不触发 cb(对齐 daoqi NewRootVC.m:314-319
// └─ 同意:RecordingPresenter 启动录音浮层 + 立即回 cb
// ↓ 用户松手 / 60s
// 录音完成 (wavURL)
// ↓
// AVAudioPlayer 读 duration → round 取整秒
// ↓
// 生成 amr 文件名(大厅 / 子游戏不同)
// ↓
// AMRCodec.wavToAmr → 七牛上传
// ↓
// 反向 callback getaudiourl + 子游戏额外 recordSuccess
//
// ## mediaTypeAudioPhase 3.F 实现,本提交仍为 stub)
//
// 本文件仍保留 stub;Phase 3.F 会替换为完整链路(下载 amr → amrToWav →
// VoicePlayer.play + gameui_play_voice / gameui_stop_voice)。
//
import Foundation
import AVFoundation
import UIKit
import os.log
public enum RemoteAudioHandler {
private static let log = Logger(subsystem: "ylgamehall", category: "RemoteAudio")
/// 注册 prepareaudio / mediaTypeAudio。
/// - Parameters:
/// - bridge: 桥
/// - hostVC: 录音浮层挂载的宿主 VClobby = WebContainerViewController
/// subGame = SubGameViewController)。每次录音重新求值,避免捕获过时引用
/// - isSubGame: 是否子游戏。子游戏录音成功后额外触发 recordSuccess
/// 反向 callback(对齐 daoqi gameController.m:2310;大厅 NewRootVC 不触发)
public static func register(
on bridge: any BridgeProtocol,
hostVC: @escaping @MainActor @Sendable () -> UIViewController?,
isSubGame: Bool
) {
// 【4】prepareaudio — 完整实现(Phase 3.E
// cb: "Response from prepareaudio"(仅录音启动成功后触发;
// 权限拒绝 / 启动失败时不触发,对齐 daoqi 行为)
bridge.register("prepareaudio") { _, callback in
Task { @MainActor in
await handlePrepareAudio(
bridge: bridge,
hostVC: hostVC,
isSubGame: isSubGame,
callback: callback
)
}
}
// 【5】mediaTypeAudio — 完整实现(Phase 3.F
// cb: "Response from mediaTypeAudio"(在下载+转码完成后触发,对齐 daoqi
// 同步阻塞下载语义;下载失败时不触发 cb)
bridge.register("mediaTypeAudio") { data, callback in
Task { @MainActor in
await handleMediaTypeAudio(
data: data,
bridge: bridge,
callback: callback
)
}
}
}
// MARK: - prepareaudio 处理(MainActor
@MainActor
private static func handlePrepareAudio(
bridge: any BridgeProtocol,
hostVC: @MainActor @Sendable () -> UIViewController?,
isSubGame: Bool,
callback: BridgeCallback?
) async {
// 1. 麦克风权限**只读**状态检查(对齐 daoqi FuncPublic.m:1144-1170 ifauth 实现)。
// 主动请求权限(requestRecordPermission)会立即弹出 iOS 系统对话框;
// 在 H5 按住状态下弹系统对话框 → 用户必须松手点"允许" →
// RecordingAwareWindow.touchObserver 捕获松手立刻 stop → 浮层瞬间关闭,
// 用户体验异常(首次启动 app 录音永远失败)。
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .denied, .restricted:
// 明确拒绝 → 弹 Alert + 不触发 cb(对齐 daoqi NewRootVC.m:314-319
showMicrophonePermissionAlert(on: hostVC())
return
case .notDetermined:
// 首次启动:弹系统权限对话框,**不**启动录音浮层(避免与 touchObserver
// 死锁——touchObserver 会捕获用户松手点"允许"的动作立刻 stop 录音)。
// 用户做完选择 / 第二次按录音按钮时走 .authorized 分支正常录音。
//
// 优先用 iOS 17+ 的 AVAudioApplication.requestRecordPermission()
// (原生 asyncSwift Concurrency 一等公民);iOS 26+ 上
// deprecated 的 AVAudioSession.requestRecordPermission(_:) +
// withCheckedContinuation 桥接路径实测会触发 Thread 10
// EXC_BREAKPOINT(疑似系统内部桥接新 API 时状态机异常)。
_ = await requestMicrophonePermission()
// ⚠️ 必须触发 cb(对齐 daoqi ifauth==1 路径行为,daoqi NewRootVC.m:322
// 在 [recorderVC beginRecordByFileName:] 后总是 responseCallback)。
//
// 不触发 cb 的后果:H5 业务在 callHandler 调用后通常会上"录音中"业务锁
// (加触摸遮罩、禁用按钮等),等收到 cb 才解锁。系统权限对话框已经把
// H5 的 in-flight touch 打断为 touchcancelH5 业务的 touchcancel 处理
// 可能不完善 → 业务锁不释放 → 整个 H5 后续无法点击。
//
// 触发 cb 让 H5 释放业务锁,即使本次实际未录音也无害(H5 会按"无
// getaudiourl"的失败路径走,与 daoqi 转码失败 silent 路径等价)。
callback?(.string("Response from prepareaudio"))
return
case .authorized:
break // 继续启动录音
@unknown default:
return
}
// 2. 启动录音浮层
guard let vc = hostVC() else {
log.error("prepareaudio: no host VC")
return
}
let fileName = VoiceRecorder.currentTimeString()
do {
try RecordingPresenter.start(on: vc, fileName: fileName) { wavURL, _ in
// 录音完成回调(@MainActor)。后续转码 / 上传是 IO 重活,挪到
// 后台 Task 避免阻塞主线程
Task.detached(priority: .userInitiated) {
await processCompletedRecording(
wavURL: wavURL,
bridge: bridge,
isSubGame: isSubGame
)
}
}
} catch {
log.error("prepareaudio start failed: \(error.localizedDescription, privacy: .public)")
return // 启动失败不触发 cb(对齐 daoqi handler 内 return 行为)
}
// 3. 启动成功,立即触发 callback(对齐 daoqi NewRootVC.m:322
// [recorderVC beginRecordByFileName:..] 之后 responseCallback
callback?(.string("Response from prepareaudio"))
}
// MARK: - 录音完成后处理(后台 Task
private static func processCompletedRecording(
wavURL: URL,
bridge: any BridgeProtocol,
isSubGame: Bool
) async {
// 1. 读 wav duration → round 取整秒(对齐 daoqi NewRootVC.m:1891-1893
// AVAudioPlayer.duration + round 行为)
let durationSec: Int
do {
let probe = try AVAudioPlayer(contentsOf: wavURL)
durationSec = Int(probe.duration.rounded())
} catch {
log.error("read wav duration failed: \(error.localizedDescription, privacy: .public)")
return
}
// 2. 生成上传文件名(大厅 vs 子游戏命名规则不同)
let amrFileName = uploadFileName(isSubGame: isSubGame)
let amrURL = wavURL.deletingLastPathComponent().appendingPathComponent(amrFileName)
// 3. WAV → AMR 转码
do {
try AMRCodec.wavToAmr(wavURL, to: amrURL)
} catch {
log.error("wav→amr failed: \(error.localizedDescription, privacy: .public)")
return
}
// 4. 七牛上传(key = amrURL.lastPathComponent,由 QiniuUploader 取)
let uploaded: UploadedFile
do {
uploaded = try await QiniuUploader.shared.upload(amrURL, timeSec: durationSec)
} catch {
log.error("qiniu upload failed: \(error.localizedDescription, privacy: .public)")
return
}
// 5. 反向 callback(在 MainActor
await MainActor.run {
// getaudiourl
// ⚠️ time 是 String%ld 整秒),不是 number — 对齐 daoqi
// NewRootVC.m:1923 / gameController.m:2305
bridge.call(
"getaudiourl",
data: .object([
"audiourl": .string(uploaded.fileUrl),
"time": .string(String(durationSec))
]),
callback: nil
)
log.debug("getaudiourl audiourl=\(uploaded.fileUrl, privacy: .public) time=\(durationSec, privacy: .public)")
// 子游戏额外 recordSuccess(对齐 daoqi gameController.m:2310
// 大厅 NewRootVC 无此 callHandler
if isSubGame {
bridge.call(
"recordSuccess",
data: .object([
"fileUrl": .string(uploaded.fileUrl),
"fileName": .string(uploaded.fileName),
"fileKey": .string(uploaded.fileKey)
]),
callback: nil
)
log.debug("recordSuccess fileKey=\(uploaded.fileKey, privacy: .public)")
}
}
}
// MARK: - 工具
/// 上传文件名(同时是七牛 key、本地 .amr 文件名):
/// 大厅:`yyyyMMddHHmmss%08X.amr` (无下划线,对齐 daoqi NewRootVC.m:1908
/// 子游戏:`yyyyMMddHHmmss_%08X.amr` (有下划线,对齐 daoqi gameController.m:2269
private static func uploadFileName(isSubGame: Bool) -> String {
let timestamp = VoiceRecorder.currentTimeString()
let random = String(format: "%08X", arc4random())
let separator = isSubGame ? "_" : ""
return "\(timestamp)\(separator)\(random).amr"
}
// MARK: - mediaTypeAudio 处理(MainActor
@MainActor
private static func handleMediaTypeAudio(
data: BridgeData?,
bridge: any BridgeProtocol,
callback: BridgeCallback?
) async {
// 1. 解析入参(只有 audiourl + user 两个字段,对齐 daoqi
// NewRootVC.m:326-328 / gameController.m:435-437
guard let obj = data?.asObject,
let urlString = obj["audiourl"]?.asString,
let user = obj["user"]?.asString,
let downloadURL = URL(string: urlString)
else {
log.error("mediaTypeAudio: bad params \(String(describing: data), privacy: .public)")
return // 参数不全 → 不触发 cb(对齐 daoqi 入参空时走到 file 不存在 return
}
// 2. 记录 lastUserId(无论是否实际播放都更新,对齐 daoqi
// `self.User_id = userid;` 总是执行的行为)
VoicePlayer.shared.recordIncomingUser(user)
// 3. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge
// [weak bridge] 让 bridge 释放后 onFinish 自动失效)
VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in
Task { @MainActor in
bridge?.call(
"gameui_stop_voice",
data: .string(finishedUser),
callback: nil
)
}
}
// 4. 异步下载 AMR
let amrData: Data
do {
let (d, _) = try await URLSession.shared.data(from: downloadURL)
amrData = d
} catch {
log.error("mediaTypeAudio download failed: \(error.localizedDescription, privacy: .public)")
return // 下载失败 → 不触发 cb(对齐 daoqi NewRootVC.m:335-337
// `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`
}
// 5. 写到沙盒 .amr 临时文件
let tempName = VoiceRecorder.currentTimeString()
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr")
try? FileManager.default.removeItem(at: amrLocal)
do {
try amrData.write(to: amrLocal)
} catch {
log.error("mediaTypeAudio write amr failed: \(error.localizedDescription, privacy: .public)")
return
}
// 6. AMR → WAV 转码
// daoqi 文件名约定:tempName + "_AmrToWav.wav"NewRootVC.m:341
let wavLocal = docs
.appendingPathComponent("\(tempName)_AmrToWav")
.appendingPathExtension("wav")
try? FileManager.default.removeItem(at: wavLocal)
let transcoded: Bool
do {
try AMRCodec.amrToWav(amrLocal, to: wavLocal)
transcoded = true
} catch {
// 转码失败:log**但 cb 仍触发**(对齐 daoqi NewRootVC.m:355-357
// 失败仅 NSLog 不影响 responseCallback 触发)
log.error("mediaTypeAudio amr→wav failed: \(error.localizedDescription, privacy: .public)")
transcoded = false
}
// 7. voicePlaying 总开关 + 实际播放 + gameui_play_voice
// (仅转码成功 + 开关 enabled 时触发,对齐 daoqi
// NewRootVC.m:343-353 if (canbofang) { play + callHandler }
let canPlay = await VoiceCenter.shared.isEnabled
if transcoded, canPlay {
do {
try VoicePlayer.shared.play(wavLocal)
// gameui_play_voice 数据是 user 字符串(非 dict)
bridge.call(
"gameui_play_voice",
data: .string(user),
callback: nil
)
log.debug("gameui_play_voice user=\(user, privacy: .public)")
} catch {
log.error("VoicePlayer.play failed: \(error.localizedDescription, privacy: .public)")
}
}
// 8. 触发 mediaTypeAudio cb(对齐 daoqi NewRootVC.m:357**总是**触发
// 即使转码失败 / canbofang=NO 都触发;仅下载失败的 return 路径不触发)
callback?(.string("Response from mediaTypeAudio"))
}
/// 麦克风权限请求(弹系统对话框)。
///
/// iOS 17+ 用新 `AVAudioApplication.requestRecordPermission()`(原生 async);
/// iOS 15.6 / 16 用 deprecated 的 `AVAudioSession.requestRecordPermission(_:)` +
/// withCheckedContinuation 桥接。
///
/// **不用** deprecated API 走新 SDK 路径的原因:实测 iOS 26.5 SDK 上 deprecated
/// API 内部桥接到 AVAudioApplication 时与 withCheckedContinuation 的状态机交互
/// 异常,会在 Thread 10 触发 EXC_BREAKPOINT(疑似系统 bug 或 Swift Concurrency
/// 与 ObjC 桥接的 corner case)。新 API 直接 await 不经 Continuation 状态机,
/// 行为稳定。
private static func requestMicrophonePermission() async -> Bool {
if #available(iOS 17.0, *) {
return await AVAudioApplication.requestRecordPermission()
} else {
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
cont.resume(returning: granted)
}
}
}
}
@MainActor
private static func showMicrophonePermissionAlert(on hostVC: UIViewController?) {
// 用户已拒绝麦克风权限时调用。iOS 不允许再次主动弹系统权限对话框
// requestRecordPermission 在 .denied 状态下不再弹窗、直接返回 false),
// 唯一恢复路径是用户手动去「设置 → app → 麦克风」开启。
//
// 文案保留 daoqi NewRootVC.m:316 的引导语;按钮提供「去授权」直接跳
// app 设置页(比 daoqi 仅"确定"更易用),「取消」关闭 Alert。
let alert = UIAlertController(
title: nil,
message: "需要访问您的麦克风,请启用麦克风-设置/隐私/麦克风!",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "去授权", style: .default) { _ in
// 跳转到本 app 在「设置」中的页面。用户在此页打开麦克风开关后回到
// app,状态自动变为 .authorized,下次 prepareaudio 走正常分支。
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
})
hostVC?.present(alert, animated: true)
}
}