Files
OSGKeyboard/OSGKeyboardExt/Services/ClipboardCaptureCoordinator.swift
T
rocky a2abfc5e8c fix: keyboard stability, host-only analytics, and auto-reply dedupe
- record analytics/usage only in the host app; the extension no longer opens the
  SQLite queues, so hiding the keyboard cannot hold WAL locks (0xdead10cc)
- sync clipboard history writes across processes so deletes made on one side
  are not reverted by the other
- guard memory-relief reentrancy on controller and telemetry sides to break
  the recursive crash loop
- resolve credentials off the main thread and migrate legacy keys once per
  install, so Keychain reads cannot be caught in the suspend window
- gate Volcengine ASR ping completion to once (SIGTRAP on double resume)
- treat same-text captures as the same logical copy and dedupe auto actions
  by content fingerprint within 30s, stopping Universal Clipboard storms
- realign version line to 2.1.0 (build 110)
2026-09-25 12:14:29 +08:00

728 lines
29 KiB
Swift

// ClipboardCaptureCoordinator.swift
// OSGKeyboard · Keyboard Extension
//
// Samples the general pasteboard on keyboard appear and while visible
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
import Combine
import Foundation
import OSGKeyboardShared
import UIKit
@MainActor
protocol ClipboardPasteboardProviding: AnyObject {
var changeCount: Int { get }
var hasStrings: Bool { get }
var string: String? { get }
}
@MainActor
final class SystemClipboardPasteboard: ClipboardPasteboardProviding {
var changeCount: Int { UIPasteboard.general.changeCount }
var hasStrings: Bool { UIPasteboard.general.hasStrings }
var string: String? { UIPasteboard.general.string }
}
@MainActor
final class ClipboardCaptureCoordinator {
/// Universal Clipboard may synchronously fetch from another device for
/// seconds. System pasteboard reads must never block keyboard presentation.
private static let readQueue = DispatchQueue(
label: "com.osgkeyboard.clipboard.read",
qos: .utility
)
private static let pollInterval: TimeInterval = 0.8
private let state: KeyboardState
private let history: ClipboardHistoryStore
private let semanticRanking: ClipboardSemanticRankingStore
private let pasteboard: ClipboardPasteboardProviding
private var pollTimer: Timer?
private var isSecureProvider: () -> Bool = { false }
private var hasFullAccessProvider: () -> Bool = { false }
/// Ephemeral only: leaving a secure field must not resurrect old body text.
private var secureFieldSuppressedChangeCount: Int?
private var isSecureEntryActive = false
private var isSampling = false
private var forcesNextSample = true
private var isKeyboardVisible = false
/// OOBE reply / translate practice auto-fires once per feature so the user
/// sees the keyboard draft the result without tapping the chip. Tracked per
/// feature (the host keeps one session id across every practice step).
private var oobeAutoFiredSessionID: UUID?
private var oobeAutoFiredFeatures: Set<ManagedGatewayOOBEFeature> = []
/// Fires the auto-reply evaluation whenever a fresh semantic analysis lands,
/// independent of which keyboard surface (voice / Chinese / English) is shown.
private var snapshotObserver: AnyCancellable?
init(
state: KeyboardState,
history: ClipboardHistoryStore = .shared,
semanticRanking: ClipboardSemanticRankingStore = .shared,
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
) {
self.state = state
self.history = history
self.semanticRanking = semanticRanking
self.pasteboard = pasteboard
// The host can delete or clear history while this extension is alive;
// without this the next capture writes our stale array back and undoes it.
history.startObservingCrossProcessChanges()
snapshotObserver = semanticRanking.$snapshot
.sink { [weak self] snapshot in
// `@Published` fires in willSet, so the store's property is not
// updated yet; hop to the main actor so the trigger reads the
// published snapshot (and satisfies actor isolation).
guard snapshot != nil else { return }
Task { @MainActor in
self?.autoTriggerActionIfNeeded()
self?.presentAutoReplyGuideIfNeeded()
}
}
}
func configure(
isSecure: @escaping () -> Bool,
hasFullAccess: @escaping () -> Bool
) {
isSecureProvider = isSecure
hasFullAccessProvider = hasFullAccess
}
func keyboardDidAppear() {
isKeyboardVisible = true
KeyboardExtensionMemoryTelemetry.record(
"clipboard.reload.begin",
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
+ "entries=\(history.entries.count)"
)
history.reload()
KeyboardExtensionMemoryTelemetry.record(
"clipboard.reload.done",
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
+ "entries=\(history.entries.count)"
)
// A suggestion belongs to one keyboard presentation. Clear any
// presentation state left behind by a reused extension controller.
endCurrentSuggestion()
if let newest = history.newestAIHintEligibleEntry() {
semanticRanking.analyze(newest)
}
forcesNextSample = true
autoTriggerOOBESkillIfNeeded()
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
// can otherwise freeze the keyboard before SwiftUI draws.
if !(pasteboard is SystemClipboardPasteboard) {
captureIfNeeded(forceRead: true)
}
startPolling()
}
func keyboardWillDisappear() {
isKeyboardVisible = false
stopPolling()
// A1 policy: closing the keyboard ends this generation's suggestion.
endCurrentSuggestion()
semanticRanking.clear()
}
func refreshFlagsFromStore() {
// Paste is available whenever clipboard history is on; only turning
// history off hides it (the old candidate-bar toggle is gone).
if !state.clipboardHistoryEnabled {
endCurrentSuggestion()
semanticRanking.clear()
}
}
func secureEntryDidChange(isSecure: Bool) {
if isSecure {
isSecureEntryActive = true
secureFieldSuppressedChangeCount = pasteboard.changeCount
} else if isSecureEntryActive {
// Capture the latest generation once more on exit so a pasteboard
// change near the secure-field transition cannot be persisted.
secureFieldSuppressedChangeCount = pasteboard.changeCount
isSecureEntryActive = false
} else {
return
}
endCurrentSuggestion()
semanticRanking.clear()
state.clipboardOverlay = .none
}
func openPanelFromTopButton() {
guard state.canShowClipboardEntry else { return }
if state.clipboardHistoryEnabled {
history.reload()
state.clipboardOverlay = .historyPanel
} else {
state.clipboardOverlay = .enableGuide
}
}
func dismissOverlay() {
state.clipboardOverlay = .none
}
func noteUserDidInputText() {
endCurrentSuggestion()
}
func dismissSuggestion() {
// Dismissing from either surface must suppress both the paste capsule
// AND the AI skill top bar for this clipboard generation. The typing
// surface's X only routes here, so set the skill marker centrally
// rather than relying on the AI surface's own dismiss handler.
state.dismissedClipboardSkillEntryID = history.newestEntry?.id
endCurrentSuggestion()
}
func insertText(_ text: String, via insert: (String) -> Void) {
guard state.canShowClipboardEntry else { return }
insert(text)
// Tapping a suggestion (or history row that shares this path) must not
// resurface the same clipboard changeCount until the pasteboard changes.
dismissSuggestion()
dismissOverlay()
}
func clearHistory() {
endCurrentSuggestion()
semanticRanking.clear()
history.clearAll()
}
func deleteEntry(id: UUID) {
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
history.remove(id: id)
if semanticRanking.snapshot?.entryID == id {
semanticRanking.clear()
}
if deletedChangeCount == state.clipboardSuggestionChangeCount {
endCurrentSuggestion()
}
}
// MARK: - Capture
private func startPolling() {
stopPolling()
let timer = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureIfNeeded()
self?.autoTriggerOOBESkillIfNeeded()
}
}
timer.tolerance = Self.pollInterval / 4
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
private func stopPolling() {
pollTimer?.invalidate()
pollTimer = nil
}
func captureIfNeeded(forceRead: Bool = false) {
guard state.clipboardHistoryEnabled else {
endCurrentSuggestion()
return
}
#if DEBUG
// What's New demo seeds history itself — never touch the pasteboard
// (avoids the simulator “允许粘贴” alert mid-recording).
if WhatsNewDemoScenario.peek() != nil || WhatsNewDemoScenario.isPlaying() {
return
}
#endif
guard hasFullAccessProvider() else { return }
guard !isSecureProvider() else {
secureEntryDidChange(isSecure: true)
return
}
if pasteboard is SystemClipboardPasteboard {
beginSystemSample(forceRead: forceRead || forcesNextSample)
forcesNextSample = false
return
}
captureInjectedPasteboard(forceRead: forceRead)
}
/// Synchronous path retained for deterministic tests and injected fakes.
/// Production always uses `beginSystemSample` below.
private func captureInjectedPasteboard(forceRead: Bool) {
let changeCount = pasteboard.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
if isCurrentGeneration && !forceRead {
return
}
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
let raw = pasteboard.string
// The forced appearance read exists only to establish/refresh iOS
// paste permission. It must not reinsert or republish old content.
if isCurrentGeneration {
return
}
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: raw),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
return
}
// A genuinely new generation replaces any previous transient suggestion.
clearSuggestion()
semanticRanking.clear()
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
}
}
private struct Sample: Sendable {
let changeCount: Int
let hasStrings: Bool
let text: String?
}
private func beginSystemSample(forceRead: Bool) {
guard !isSampling else { return }
isSampling = true
let lastObserved = history.lastObservedChangeCount
Self.readQueue.async {
let pasteboard = UIPasteboard.general
let changeCount = pasteboard.changeCount
guard forceRead || changeCount != lastObserved else {
Task { @MainActor [weak self] in
self?.isSampling = false
}
return
}
let hasStrings = pasteboard.hasStrings
let sample = Sample(
changeCount: changeCount,
hasStrings: hasStrings,
text: hasStrings ? pasteboard.string : nil
)
Task { @MainActor [weak self] in
self?.finishSystemSample(sample)
}
}
}
private func finishSystemSample(_ sample: Sample) {
isSampling = false
guard isKeyboardVisible,
state.clipboardHistoryEnabled,
hasFullAccessProvider(),
!isSecureProvider()
else { return }
let changeCount = sample.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: sample.text),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
KeyboardExtensionMemoryTelemetry.record(
"clipboard.swallowDuplicate",
details: "changeCount=\(changeCount)"
)
return
}
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
guard sample.hasStrings else {
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
}
// Forced appearance reads establish iOS paste permission only. Never
// republish content from an already observed generation.
guard !isCurrentGeneration else { return }
semanticRanking.clear()
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
semanticRanking.analyze(entry)
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
}
}
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
// The one-tap Paste capsule is always on (its old opt-in toggle was
// removed), so this only depends on clipboard history + a secure field.
guard state.canShowClipboardEntry,
changeCount != secureFieldSuppressedChangeCount
else {
clearSuggestion()
return
}
// Already used/dismissed this pasteboard generation — keep it hidden.
if history.suggestionDismissedChangeCount == changeCount {
clearSuggestion()
return
}
state.clipboardSuggestionText = entry.text
state.clipboardSuggestionChangeCount = changeCount
}
private func endCurrentSuggestion() {
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
clearSuggestion()
}
private func clearSuggestion() {
guard state.clipboardSuggestionText != nil
|| state.clipboardSuggestionChangeCount != nil
else {
return
}
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
}
// MARK: - Auto mode
/// Auto mode: on every fresh semantic analysis, pick at most one automatic
/// action for the newest clipboard and route it into the assistant surface
/// with no tap. Surface-independent (voice / Chinese / English) and fired at
/// most once per pasteboard generation, persisted so a reopen never repeats.
///
/// Precedence when several toggles apply to the same paste:
/// email reply (a detected email should be answered, not just translated —
/// even in another language) → translate (foreign, non-email) → generic reply.
private func autoTriggerActionIfNeeded() {
guard state.clipboardHistoryEnabled,
state.aiServiceAvailable,
state.oobePracticeSession == nil,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
guard state.clipboardAutoModeEnabled
|| state.clipboardAutoTranslateEnabled
|| state.clipboardAutoEmailReplyEnabled else {
return
}
guard let newest = history.newestEntry,
let changeCount = newest.changeCount else {
return
}
// One auto action per copy, even across keyboard close/reopen.
guard history.lastAutoRepliedChangeCount != changeCount else { return }
// Universal Clipboard re-announces one copy under several changeCounts,
// each a fresh "generation" here. Suppress identical content inside the
// window; a deliberate re-copy after it may trigger again.
guard !history.recentlyAutoReplied(text: newest.text) else { return }
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
) else {
return
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return
}
let catalog = state.clipboardSkillCatalog
// 1. Auto-draft a single, email-formatted reply for a detected email.
// Checked before translate so a foreign-language email is answered
// rather than merely translated. Staged like auto-translate: shown on
// the keyboard, inserted only on the glass button.
if state.clipboardAutoEmailReplyEnabled,
ClipboardEmailDetector.isEmail(newest.text) {
fireAutoAction(
AIClipboardSkillCatalog.emailReplySkill,
scene: nil,
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true,
insertLabelKey: "keyboard.assistant.insertReply"
)
return
}
// 2. Auto-translate a non-system-language, non-email paste (staged).
if state.clipboardAutoTranslateEnabled,
ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis),
let translate = catalog.first(where: { $0.id == AIClipboardSkillCatalog.translateID }) {
fireAutoAction(
translate,
scene: nil,
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true
)
return
}
// 3. Auto-reply for any interpersonal message worth answering — a task,
// question, invitation, complaint, follow-up, or an explicit "please
// reply". Gated on reply intent (not strict #1 ranking) so a message
// that also scores Events / Todos / Summary still auto-replies. Bare
// links / phones / foreign text keep their own actions.
if state.clipboardAutoModeEnabled,
ClipboardSkillSemanticRanker.isAutoReplyEligible(
sourceText: newest.text,
analysis: snapshot.analysis
),
let reply = catalog.first(where: { $0.id == AIClipboardSkillCatalog.replyID }) {
fireAutoAction(
reply,
scene: AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
),
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: false
)
}
}
/// Submit the chosen auto action, remembering the keyboard to return to and
/// taking over the assistant surface so the result is visible.
private func fireAutoAction(
_ skill: AIClipboardSkill,
scene: AIClipboardReplyScene?,
changeCount: Int,
sourceText: String,
readOnlyResult: Bool,
insertLabelKey: String = "keyboard.assistant.insertTranslation"
) {
// Mark handled before submitting so re-entrant publishes cannot double-fire.
history.markAutoReplied(text: sourceText, changeCount: changeCount)
KeyboardExtensionMemoryTelemetry.record(
"clipboard.autoFire",
details: "skill=\(skill.id) changeCount=\(changeCount)"
)
// Remember the keyboard to return to once the result is used or closed:
// the surface the user is actively on, or — when the keyboard opened
// straight onto voice — the one they last left, so finishing never
// strands them on voice input. nil means "already where we'd land".
let returnSurface: KeyboardState.Surface = state.surface != .voice
? state.surface
: TypingInputConfiguration.lastLeftSurface()
state.autoReplyReturnSurface = returnSurface == .voice ? nil : returnSurface
// Staged auto results (translate / email reply) wait for a glass-button
// tap; generic replies use their own variant surface.
state.autoResultReadOnly = readOnlyResult
state.autoResultInsertLabelKey = insertLabelKey
// The result UI only renders on the assistant surface, so take it over.
if state.surface != .voice {
state.setSurface(.voice)
}
state.submitAIClipboardSkill(skill, scene)
}
// MARK: - Auto-reply guide (one-time nudge)
/// Persisted "the user has already responded to the nudge once" flag. Stored
/// in the extension's own defaults; the guide is a keyboard-local affordance.
private static let autoReplyGuideTriedKey = "keyboard.assistant.autoReplyGuidanceTried"
private static var autoReplyGuideTried: Bool {
get { UserDefaults.standard.bool(forKey: autoReplyGuideTriedKey) }
set { UserDefaults.standard.set(newValue, forKey: autoReplyGuideTriedKey) }
}
/// Only auto-present once per pasteboard generation, so dismissing the layer
/// does not immediately resurface it for the same copy.
private var lastGuidedChangeCount: Int?
/// Universal Clipboard re-announces one copy under several changeCounts, so
/// the content fingerprint + present time suppress those repeats too.
private var lastGuidedTextFingerprint: String?
private var lastGuidedAt: Date?
/// For users who never turned auto mode on: when a freshly copied message
/// reads as replyable, raise the clean full-keyboard guide instead of firing
/// a reply. Shown on every replyable copy until the user acts on it once.
private func presentAutoReplyGuideIfNeeded() {
guard !state.clipboardAutoModeEnabled,
!Self.autoReplyGuideTried,
state.clipboardHistoryEnabled,
state.aiServiceAvailable,
state.oobePracticeSession == nil,
state.canShowClipboardEntry,
state.clipboardOverlay == .none,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
guard let newest = history.newestEntry,
let changeCount = newest.changeCount,
lastGuidedChangeCount != changeCount else {
return
}
let textFingerprint = ClipboardHistoryPolicy.contentFingerprint(for: newest.text)
if lastGuidedTextFingerprint == textFingerprint,
let lastGuidedAt,
ClipboardHistoryPolicy.isRepeatSuppressed(firedAt: lastGuidedAt) {
return
}
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
) else {
return
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return
}
let recommended = ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 5
)
guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return }
lastGuidedChangeCount = changeCount
lastGuidedTextFingerprint = textFingerprint
lastGuidedAt = Date()
state.clipboardOverlay = .autoReplyGuide
}
/// The guide's primary action: opt into auto mode (persisted so it sticks),
/// close the layer, and draft the reply for the copy that prompted it — the
/// same submission every future auto reply now makes on its own.
func confirmAutoReplyGuide() {
Self.autoReplyGuideTried = true
AppGroupStore().setClipboardAutoModeEnabled(true)
state.clipboardAutoModeEnabled = true
state.clipboardOverlay = .none
guard let newest = history.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id,
let reply = state.clipboardSkillCatalog.first(where: {
$0.id == AIClipboardSkillCatalog.replyID
}) else {
return
}
let scene = AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
)
fireAutoAction(
reply,
scene: scene,
changeCount: newest.changeCount ?? history.lastObservedChangeCount,
sourceText: newest.text,
readOnlyResult: false
)
}
/// OOBE reply / translate practice: once the host has seeded the demo
/// message, run the skill automatically — the same submission the chip makes
/// — so the user experiences the auto skill before any setting is turned on.
/// Runs off the poll tick (OOBE material is not semantic-ranked), and fires
/// at most once per practice feature within the host session. The translate
/// result is staged (glass Insert button) exactly like the live auto flow.
private func autoTriggerOOBESkillIfNeeded() {
guard let session = state.oobePracticeSession,
state.aiServiceAvailable,
isKeyboardVisible,
isRestingForAutoReply else {
return
}
let feature = session.expectedFeature
let skillID: String
switch feature {
case .clipboardReply:
skillID = AIClipboardSkillCatalog.replyID
case .clipboardTranslate:
skillID = AIClipboardSkillCatalog.translateID
case .voiceInput, .askAI:
return
}
// The host keeps one session id across steps, so gate per feature.
if oobeAutoFiredSessionID != session.sessionID {
oobeAutoFiredSessionID = session.sessionID
oobeAutoFiredFeatures = []
}
guard !oobeAutoFiredFeatures.contains(feature) else { return }
guard KeyboardSetupBridge.oobeClipboardMaterial(
sessionID: session.sessionID
) != nil else {
return
}
guard let skill = state.clipboardSkillCatalog.first(where: {
$0.id == skillID
}) else {
return
}
// Mark handled before submitting so a re-entrant tick cannot double-fire.
oobeAutoFiredFeatures.insert(feature)
// Stage the translation like the live auto-translate: shown on the
// keyboard, inserted only when the user taps the glass button.
if feature == .clipboardTranslate {
state.autoResultReadOnly = true
state.autoResultInsertLabelKey = "keyboard.assistant.insertTranslation"
}
// The result UI only renders on the assistant surface, so take it over.
if state.surface != .voice {
state.setSurface(.voice)
}
state.submitAIClipboardSkill(skill, nil)
}
/// The voice pipeline must be idle before auto mode takes over the surface.
private var isRestingForAutoReply: Bool {
guard !state.aiSession.isBusy,
!state.aiSession.canInsert,
!state.aiSession.canSelectReplyVariant,
!state.editSession.isActive else {
return false
}
switch state.phase {
case .idle, .error, .denied:
return true
case .requestingPermissions, .recording, .processing:
return false
}
}
}