Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift
Peter Steinberger b978dcc63b feat(ios): record and send voice notes from the chat composer (#100946)
* feat(ios): record and send voice notes from the chat composer

Adds tap-to-record voice notes to the shared chat composer: m4a/AAC mono
capture with a 3-minute cap, lazy mic permission, cancel/finish controls,
audio attachment chips with duration, and a voice-note transcript row.
Voice-wake suppression becomes reason-scoped so recording and Talk cannot
clobber each other. Attachments stay online-only per the outbox contract.

Related: #100709

* fix(ios): harden voice note attachment staging

* chore(ios): keep voice note changelog release-owned

* fix(ios): gate voice notes with attachment availability

* chore(ios): refresh native localization inventory
2026-07-07 00:14:05 +01:00

114 lines
3.6 KiB
Swift

import Foundation
import OpenClawKit
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension OpenClawChatViewModel {
/// Stages a recorded m4a voice note and removes its temporary file.
func addVoiceNoteAttachment(fileURL: URL, durationSeconds: Double) async {
defer { try? FileManager.default.removeItem(at: fileURL) }
let data: Data
do {
data = try await Task.detached(priority: .userInitiated) {
try Data(contentsOf: fileURL)
}.value
} catch {
self.errorText = String(localized: "Could not attach voice note: \(error.localizedDescription)")
return
}
guard data.count <= Self.maxAttachmentBytes else {
self.errorText = String(localized: "Voice note exceeds the 5 MB attachment limit")
return
}
self.attachments.append(
OpenClawPendingAttachment(
url: nil,
data: data,
fileName: fileURL.lastPathComponent,
mimeType: "audio/mp4",
preview: nil,
durationSeconds: max(0, durationSeconds)))
}
func loadAttachments(urls: [URL]) async {
for url in urls {
do {
let data = try await Task.detached { try Data(contentsOf: url) }.value
await self.addImageAttachment(
url: url,
data: data,
fileName: url.lastPathComponent,
mimeType: Self.mimeType(for: url) ?? "application/octet-stream")
} catch {
await MainActor.run { self.errorText = error.localizedDescription }
}
}
}
static func mimeType(for url: URL) -> String? {
let ext = url.pathExtension
guard !ext.isEmpty else { return nil }
return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType
}
func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async {
let uti: UTType = {
if let url {
return UTType(filenameExtension: url.pathExtension) ?? .data
}
return UTType(mimeType: mimeType) ?? .data
}()
guard uti.conforms(to: .image) else {
self.errorText = "Only image attachments are supported right now"
return
}
let processed: Data
do {
processed = try await Task.detached(priority: .userInitiated) {
try ChatImageProcessor.processForUpload(data: data)
}.value
} catch {
self.errorText = "Could not process \(fileName): \(error.localizedDescription)"
return
}
if processed.count > Self.maxAttachmentBytes {
self.errorText = "Attachment \(fileName) exceeds 5 MB limit after resizing"
return
}
let outputFileName: String = {
let baseName = (fileName as NSString).deletingPathExtension
return baseName.isEmpty ? "image.jpg" : "\(baseName).jpg"
}()
let preview = Self.previewImage(data: processed)
self.attachments.append(
OpenClawPendingAttachment(
url: url,
data: processed,
fileName: outputFileName,
mimeType: "image/jpeg",
preview: preview))
}
static func previewImage(data: Data) -> OpenClawPlatformImage? {
#if canImport(AppKit)
NSImage(data: data)
#elseif canImport(UIKit)
UIImage(data: data)
#else
nil
#endif
}
}