// 1A spike 核心:键盘扩展内尝试 AVAudioEngine 录音,结果直接显示在键盘上。 // 判定标准: // ✅ engine.start() 成功且 1 秒内收到音频帧 → 键盘内录音可行(主路径成立) // ❌ setActive/start 抛错或 0 帧 → 不可行,走 deep link 降级方案(14D) import AVFoundation import UIKit class KeyboardViewController: UIInputViewController { private let resultLabel = UILabel() private let engine = AVAudioEngine() private var frames: UInt32 = 0 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemGray5 let stack = UIStackView() stack.axis = .vertical stack.spacing = 10 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), stack.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -32), view.heightAnchor.constraint(greaterThanOrEqualToConstant: 220), ]) let info = UILabel() info.text = "完全访问:\(hasFullAccess ? "已开启" : "未开启(请先开启)")" info.font = .systemFont(ofSize: 14) stack.addArrangedSubview(info) let btn = UIButton(type: .system) btn.setTitle("测试键盘内录音(1 秒)", for: .normal) btn.titleLabel?.font = .boldSystemFont(ofSize: 17) btn.addTarget(self, action: #selector(testMic), for: .touchUpInside) stack.addArrangedSubview(btn) resultLabel.text = "结果:未测试" resultLabel.font = .monospacedSystemFont(ofSize: 14, weight: .regular) resultLabel.numberOfLines = 0 stack.addArrangedSubview(resultLabel) let next = UIButton(type: .system) next.setTitle("🌐 切回系统键盘", for: .normal) next.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents) stack.addArrangedSubview(next) } @objc private func testMic() { frames = 0 resultLabel.text = "结果:测试中…" // 键盘扩展无法弹权限框;权限继承容器 App / 系统设置中的麦克风授权 do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers]) try session.setActive(true) engine.inputNode.removeTap(onBus: 0) engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: nil) { [weak self] buf, _ in self?.frames += buf.frameLength } try engine.start() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self else { return } self.engine.stop() self.engine.inputNode.removeTap(onBus: 0) let permission = AVAudioApplication.shared.recordPermission self.resultLabel.text = self.frames > 0 ? "结果:✅ 收到 \(self.frames) 帧 — 键盘内录音可行" : "结果:❌ 0 帧(权限态:\(permission.rawValue))— 需走降级方案" } } catch { resultLabel.text = "结果:❌ \(error.localizedDescription) — 需走降级方案" } } }