This commit is contained in:
wt committed 2026-07-30 20:07:51 +07:00
commit 92660a4ba0
228 files changed
+24496

No files matched your search

@@ -0,0 +1,147 @@
import AVFoundation
import Combine
import Foundation
@MainActor
final class AudioMessageRecorder: NSObject, ObservableObject {
struct Recording {
let url: URL
let duration: TimeInterval
}
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
private var recorder: AVAudioRecorder?
private var timer: Timer?
private var startedAt: Date?
func start() async throws {
guard !isRecording else { return }
guard await requestPermission() else { throw AudioRecorderError.permissionDenied }
guard !Task.isCancelled else { throw CancellationError() }
var didStart = false
defer {
#if os(iOS)
if !didStart {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
try session.setActive(true)
#endif
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.prepareToRecord()
guard recorder.record() else { throw AudioRecorderError.cannotStart }
self.recorder = recorder
startedAt = Date()
elapsed = 0
isRecording = true
didStart = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = Date().timeIntervalSince(startedAt)
}
}
}
func finish() throws -> Recording {
guard let recorder, isRecording else { throw AudioRecorderError.notRecording }
let duration = max(recorder.currentTime, elapsed)
let url = recorder.url
recorder.stop()
stopState()
guard duration >= 0.4 else {
try? FileManager.default.removeItem(at: url)
throw AudioRecorderError.tooShort
}
return Recording(url: url, duration: duration)
}
func cancel() {
let url = recorder?.url
recorder?.stop()
stopState()
if let url {
try? FileManager.default.removeItem(at: url)
}
}
private func stopState() {
timer?.invalidate()
timer = nil
recorder = nil
startedAt = nil
elapsed = 0
isRecording = false
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
private func requestPermission() async -> Bool {
#if os(iOS)
let session = AVAudioApplication.shared
switch session.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) }
}
@unknown default:
return false
}
#else
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .audio)
@unknown default:
return false
}
#endif
}
}
private enum AudioRecorderError: LocalizedError {
case permissionDenied
case cannotStart
case notRecording
case tooShort
var errorDescription: String? {
switch self {
case .permissionDenied:
return "Разрешите Luma доступ к микрофону в системных настройках."
case .cannotStart:
return "Не удалось начать запись голоса."
case .notRecording:
return "Запись голоса не запущена."
case .tooShort:
return "Голосовое сообщение получилось слишком коротким."
}
}
}
@@ -0,0 +1,49 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
enum AvatarImageProcessor {
static func pngData(from sourceData: Data) throws -> Data {
guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 512
] as CFDictionary
) else {
throw AvatarImageError.invalidImage
}
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output,
UTType.png.identifier as CFString,
1,
nil
) else {
throw AvatarImageError.encodingFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw AvatarImageError.encodingFailed
}
return output as Data
}
}
private enum AvatarImageError: LocalizedError {
case invalidImage
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidImage:
return "Не удалось прочитать выбранное изображение."
case .encodingFailed:
return "Не удалось подготовить аватар в формате PNG."
}
}
}
@@ -0,0 +1,35 @@
import Foundation
#if os(iOS)
import UIKit
typealias ChatMediaPlatformImage = UIImage
#elseif os(macOS)
import AppKit
typealias ChatMediaPlatformImage = NSImage
#endif
/// SwiftUI can evaluate every visible bubble repeatedly during scrolling.
/// Keep decoded thumbnails in a bounded native-image cache so those passes do
/// not repeatedly decompress JPEG/HEIC data on the main actor.
@MainActor
enum ChatMediaImageCache {
private static let cache: NSCache<NSString, ChatMediaPlatformImage> = {
let cache = NSCache<NSString, ChatMediaPlatformImage>()
cache.countLimit = 160
cache.totalCostLimit = 48 * 1_024 * 1_024
return cache
}()
static func image(
for message: ChatMessage,
data: Data
) -> ChatMediaPlatformImage? {
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
if let cached = cache.object(forKey: cacheKey) {
return cached
}
guard let image = ChatMediaPlatformImage(data: data) else { return nil }
cache.setObject(image, forKey: cacheKey, cost: data.count)
return image
}
}
@@ -0,0 +1,94 @@
import CoreLocation
import Combine
import Foundation
@MainActor
final class LocationProvider: NSObject, ObservableObject {
@Published private(set) var location: CLLocation?
@Published private(set) var authorizationStatus: CLAuthorizationStatus
@Published private(set) var errorMessage: String?
@Published private(set) var isLocating = false
private let manager: CLLocationManager
override init() {
let manager = CLLocationManager()
self.manager = manager
authorizationStatus = manager.authorizationStatus
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
func requestCurrentLocation() {
errorMessage = nil
switch manager.authorizationStatus {
case .notDetermined:
isLocating = true
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
isLocating = true
manager.requestLocation()
case .denied, .restricted:
isLocating = false
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
@unknown default:
isLocating = false
errorMessage = "Не удалось определить статус доступа к геопозиции."
}
}
}
extension LocationProvider: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor [weak self] in
guard let self else { return }
authorizationStatus = status
switch status {
case .authorizedAlways, .authorizedWhenInUse:
isLocating = true
self.manager.requestLocation()
case .denied, .restricted:
isLocating = false
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
case .notDetermined:
break
@unknown default:
isLocating = false
}
}
}
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let newest = locations.last else { return }
let latitude = newest.coordinate.latitude
let longitude = newest.coordinate.longitude
let altitude = newest.altitude
let horizontalAccuracy = newest.horizontalAccuracy
let verticalAccuracy = newest.verticalAccuracy
let timestamp = newest.timestamp
Task { @MainActor [weak self] in
self?.location = CLLocation(
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
altitude: altitude,
horizontalAccuracy: horizontalAccuracy,
verticalAccuracy: verticalAccuracy,
timestamp: timestamp
)
self?.isLocating = false
self?.errorMessage = nil
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let description = error.localizedDescription
Task { @MainActor [weak self] in
self?.isLocating = false
self?.errorMessage = "Не удалось получить текущую геопозицию: \(description)"
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import Foundation
import UniformTypeIdentifiers
struct LoadedMediaFile: Sendable {
let data: Data
let filename: String
let mimeType: String
let inferredKind: ChatMessage.Kind
}
struct StagedMediaFile: Sendable {
let url: URL
let filename: String
let contentTypeIdentifier: String?
let byteCount: Int
}
enum MediaFileIOError: LocalizedError, Sendable {
case coordinatedReadFailed(String)
case emptyFile(String)
case unreadableFile(String)
var errorDescription: String? {
switch self {
case .coordinatedReadFailed(let message):
return "Не удалось получить выбранный файл: \(message)"
case .emptyFile(let filename):
return "Файл «\(filename)» пуст или ещё не загружен из iCloud."
case .unreadableFile(let filename):
return "Не удалось подготовить «\(filename)» для отправки."
}
}
}
/// Serializes potentially large media file reads and writes away from the
/// main actor so finishing a video note cannot freeze the composer.
actor MediaFileIO {
/// Monal immediately copies an item-provider URL into its own cache under
/// NSFileCoordinator before preview or upload work starts. Do the same for
/// every Luma draft so Photos/iCloud temporary URLs can disappear safely.
func stageAttachmentDraft(from source: URL) throws -> StagedMediaFile {
let fileManager = FileManager.default
let directory = fileManager.temporaryDirectory
.appendingPathComponent("LumaAttachmentDrafts", isDirectory: true)
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let accessed = source.startAccessingSecurityScopedResource()
defer {
if accessed { source.stopAccessingSecurityScopedResource() }
}
var coordinatedError: NSError?
var stagedResult: Result<StagedMediaFile, Error>?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(
readingItemAt: source,
options: [],
error: &coordinatedError
) { coordinatedURL in
stagedResult = Result {
let originalValues = try? source.resourceValues(
forKeys: [.nameKey, .contentTypeKey]
)
let coordinatedValues = try coordinatedURL.resourceValues(
forKeys: [.fileSizeKey, .contentTypeKey]
)
let sourceName = originalValues?.name ?? source.lastPathComponent
let filename = sourceName.isEmpty
? coordinatedURL.lastPathComponent
: sourceName
let safeComponent = Self.safePathComponent(filename)
let destination = directory.appendingPathComponent(
"\(UUID().uuidString)-\(safeComponent)"
)
do {
try fileManager.copyItem(at: coordinatedURL, to: destination)
let stagedValues = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
let byteCount = stagedValues.fileSize ?? coordinatedValues.fileSize ?? 0
guard stagedValues.isRegularFile == true,
fileManager.isReadableFile(atPath: destination.path) else {
throw MediaFileIOError.unreadableFile(filename)
}
guard byteCount > 0 else {
throw MediaFileIOError.emptyFile(filename)
}
let contentType = originalValues?.contentType
?? coordinatedValues.contentType
?? UTType(filenameExtension: source.pathExtension)
return StagedMediaFile(
url: destination,
filename: filename,
contentTypeIdentifier: contentType?.identifier,
byteCount: byteCount
)
} catch {
try? fileManager.removeItem(at: destination)
throw error
}
}
}
if let stagedResult {
return try stagedResult.get()
}
throw MediaFileIOError.coordinatedReadFailed(
coordinatedError?.localizedDescription ?? "неизвестная ошибка"
)
}
func load(
from url: URL,
preferredKind: ChatMessage.Kind?
) throws -> LoadedMediaFile {
let values = try url.resourceValues(forKeys: [.nameKey, .contentTypeKey])
let filename = values.name ?? url.lastPathComponent
let contentType = values.contentType ?? UTType(filenameExtension: url.pathExtension)
let kind = preferredKind ?? Self.mediaKind(
for: contentType,
filename: filename
)
return LoadedMediaFile(
data: try Data(contentsOf: url, options: [.mappedIfSafe]),
filename: filename,
mimeType: contentType?.preferredMIMEType ?? "application/octet-stream",
inferredKind: kind
)
}
func write(_ data: Data, to url: URL) throws {
try data.write(to: url, options: [.atomic])
}
private static func safePathComponent(_ value: String) -> String {
let forbidden = CharacterSet(charactersIn: "/\\:")
.union(.controlCharacters)
let cleaned = value.components(separatedBy: forbidden).joined(separator: "_")
return cleaned.isEmpty ? "attachment" : cleaned
}
private static func mediaKind(
for contentType: UTType?,
filename: String
) -> ChatMessage.Kind {
if contentType?.conforms(to: .image) == true { return .photo }
if contentType?.conforms(to: .movie) == true { return .video }
if contentType?.conforms(to: .audio) == true { return .audio }
let fallback = UTType(
filenameExtension: URL(fileURLWithPath: filename).pathExtension
)
if fallback?.conforms(to: .image) == true { return .photo }
if fallback?.conforms(to: .movie) == true { return .video }
if fallback?.conforms(to: .audio) == true { return .audio }
return .attachment
}
}
@@ -0,0 +1,207 @@
import AVFoundation
import Combine
import Foundation
extension Notification.Name {
static let lumaExclusiveMediaPlayback = Notification.Name("app.luma.exclusiveMediaPlayback")
}
@MainActor
final class MediaPlaybackCoordinator: ObservableObject {
@Published private(set) var currentMessageID: String?
@Published private(set) var isPlaying = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var voiceRate: Double = 1
private var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var currentIsVoice = false
private var durationTask: Task<Void, Never>?
deinit {
durationTask?.cancel()
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
}
func toggle(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool
) {
if currentMessageID != messageID {
prepare(
messageID: messageID,
url: url,
durationHint: durationHint,
isVoice: isVoice
)
}
guard player != nil else { return }
isPlaying ? pause() : play()
}
func seek(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool,
fraction: Double
) {
if currentMessageID != messageID {
prepare(
messageID: messageID,
url: url,
durationHint: durationHint,
isVoice: isVoice
)
}
guard let player else { return }
let targetDuration = max(duration, durationHint ?? 0)
guard targetDuration > 0 else { return }
let seconds = max(0, min(1, fraction)) * targetDuration
currentTime = seconds
player.seek(
to: CMTime(seconds: seconds, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
func cycleVoiceRate() {
if voiceRate < 1.25 {
voiceRate = 1.5
} else if voiceRate < 1.75 {
voiceRate = 2
} else {
voiceRate = 1
}
guard currentIsVoice, isPlaying else { return }
player?.playImmediately(atRate: Float(voiceRate))
}
func stop() {
durationTask?.cancel()
durationTask = nil
player?.pause()
removeObservers()
player = nil
currentMessageID = nil
isPlaying = false
currentTime = 0
duration = 0
currentIsVoice = false
}
private func prepare(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool
) {
stop()
let player = AVPlayer(url: url)
player.actionAtItemEnd = .pause
self.player = player
currentMessageID = messageID
currentIsVoice = isVoice
currentTime = 0
duration = Self.validDuration(durationHint) ?? 0
installObservers(for: player)
if duration == 0 {
durationTask = Task { [weak self] in
let asset = AVURLAsset(url: url)
guard let time = try? await asset.load(.duration),
!Task.isCancelled else { return }
let seconds = time.seconds
guard seconds.isFinite, seconds > 0,
self?.currentMessageID == messageID else { return }
self?.duration = seconds
}
}
}
private func play() {
guard let player, let currentMessageID else { return }
configureAudioSession()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: currentMessageID
)
if currentIsVoice {
player.playImmediately(atRate: Float(voiceRate))
} else {
player.play()
}
isPlaying = true
}
private func pause() {
player?.pause()
isPlaying = false
}
private func installObservers(for player: AVPlayer) {
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
queue: .main
) { [weak self, weak player] time in
Task { @MainActor in
guard let self, let player else { return }
let seconds = time.seconds
if seconds.isFinite {
self.currentTime = max(0, seconds)
}
if let itemDuration = player.currentItem?.duration.seconds,
itemDuration.isFinite,
itemDuration > 0 {
self.duration = itemDuration
}
}
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: .main
) { [weak self, weak player] _ in
Task { @MainActor in
player?.seek(to: .zero)
self?.currentTime = 0
self?.isPlaying = false
}
}
}
private func removeObservers() {
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
}
timeObserver = nil
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = nil
}
private func configureAudioSession() {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
let mode: AVAudioSession.Mode = currentIsVoice ? .spokenAudio : .default
try? session.setCategory(.playback, mode: mode, options: [.allowAirPlay, .allowBluetoothA2DP])
try? session.setActive(true)
#endif
}
private static func validDuration(_ value: TimeInterval?) -> TimeInterval? {
guard let value, value.isFinite, value > 0 else { return nil }
return value
}
}
@@ -0,0 +1,195 @@
import AVFoundation
import Foundation
import ImageIO
import UniformTypeIdentifiers
actor MediaPreviewProcessor {
struct Analysis: Sendable {
let duration: TimeInterval?
let thumbnailData: Data?
let waveform: [Float]?
}
static let placeholderWaveform: [Float] = [
0.24, 0.42, 0.30, 0.58, 0.76, 0.48, 0.34, 0.67,
0.88, 0.61, 0.40, 0.73, 0.52, 0.31, 0.64, 0.82,
0.45, 0.69, 0.91, 0.56, 0.38, 0.71, 0.49, 0.79,
0.57, 0.35, 0.62, 0.86, 0.53, 0.72, 0.43, 0.65,
0.84, 0.51, 0.32, 0.59, 0.75, 0.46, 0.68, 0.39,
0.80, 0.55, 0.36, 0.70, 0.47, 0.63, 0.78, 0.44
]
func analyze(
url: URL,
kind: ChatMessage.Kind,
mimeType: String? = nil
) async -> Analysis {
let asset = Self.mediaAsset(url: url, mimeType: mimeType)
let duration: TimeInterval?
if [.video, .audio, .voice, .videoNote].contains(kind) {
duration = await mediaDuration(for: asset)
} else {
duration = nil
}
let thumbnailData: Data?
if kind == .photo {
thumbnailData = imageThumbnailData(for: url)
} else if kind == .video || kind == .videoNote {
thumbnailData = await videoThumbnailData(
for: asset,
duration: duration
)
} else {
thumbnailData = nil
}
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
return Analysis(
duration: duration,
thumbnailData: thumbnailData,
waveform: waveform
)
}
private func mediaDuration(for asset: AVURLAsset) async -> TimeInterval? {
guard let time = try? await asset.load(.duration) else { return nil }
let seconds = time.seconds
return seconds.isFinite && seconds > 0 ? seconds : nil
}
private func videoThumbnailData(
for asset: AVURLAsset,
duration: TimeInterval?
) async -> Data? {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 960, height: 960)
generator.requestedTimeToleranceBefore = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
generator.requestedTimeToleranceAfter = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
let safeDuration = duration.flatMap { value in
value.isFinite && value > 0 ? value : nil
}
let preferredSecond = safeDuration.map {
min(1, max(0.01, $0 * 0.25))
} ?? 0.5
let earlySecond = safeDuration.map {
min(0.05, max(0, $0 * 0.1))
} ?? 0.05
var attemptedMilliseconds: Set<Int> = []
for second in [preferredSecond, earlySecond, 0] {
let key = Int((second * 1_000).rounded())
guard attemptedMilliseconds.insert(key).inserted else { continue }
do {
let result = try await generator.image(
at: CMTime(seconds: second, preferredTimescale: 600)
)
if let data = jpegData(from: result.image) {
return data
}
} catch {
continue
}
}
return nil
}
private func imageThumbnailData(for url: URL) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 1_200,
kCGImageSourceShouldCacheImmediately: true
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
options as CFDictionary
) else { return nil }
return jpegData(from: image)
}
private func jpegData(from image: CGImage) -> Data? {
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output as CFMutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else { return nil }
let options = [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary
CGImageDestinationAddImage(destination, image, options)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func mediaAsset(url: URL, mimeType: String?) -> AVURLAsset {
guard let mimeType, !mimeType.isEmpty else {
return AVURLAsset(url: url)
}
// Supplying the MIME type is important for picker files whose temporary
// extension does not describe the real QuickTime/HEVC container.
return AVURLAsset(
url: url,
options: ["AVURLAssetOutOfBandMIMETypeKey": mimeType]
)
}
private func voiceWaveform(for url: URL, sampleCount: Int = 48) -> [Float]? {
do {
let file = try AVAudioFile(
forReading: url,
commonFormat: .pcmFormatFloat32,
interleaved: false
)
let totalFrames = max(1, file.length)
let capacity: AVAudioFrameCount = 4_096
guard let buffer = AVAudioPCMBuffer(
pcmFormat: file.processingFormat,
frameCapacity: capacity
) else { return nil }
var peaks = Array(repeating: Float.zero, count: sampleCount)
var processedFrames: AVAudioFramePosition = 0
while processedFrames < totalFrames {
let remaining = totalFrames - processedFrames
let requested = AVAudioFrameCount(min(AVAudioFramePosition(capacity), remaining))
try file.read(into: buffer, frameCount: requested)
let frameLength = Int(buffer.frameLength)
guard frameLength > 0, let channels = buffer.floatChannelData else { break }
let channelCount = Int(buffer.format.channelCount)
for frame in 0..<frameLength {
let absoluteFrame = processedFrames + AVAudioFramePosition(frame)
let bin = min(
sampleCount - 1,
Int(absoluteFrame * AVAudioFramePosition(sampleCount) / totalFrames)
)
var peak = Float.zero
for channel in 0..<channelCount {
peak = max(peak, abs(channels[channel][frame]))
}
peaks[bin] = max(peaks[bin], peak)
}
processedFrames += AVAudioFramePosition(frameLength)
}
guard let maximum = peaks.max(), maximum > 0 else {
return Self.placeholderWaveform
}
return peaks.map { peak in
max(0.08, min(1, sqrt(peak / maximum)))
}
} catch {
return Self.placeholderWaveform
}
}
}
@@ -0,0 +1,37 @@
import Foundation
import UserNotifications
final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate {
override init() {
super.init()
// UNUserNotificationCenter keeps a weak delegate. AppModel retains this
// coordinator for the lifetime of the application.
UNUserNotificationCenter.current().delegate = self
}
func requestAuthorization() async {
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
}
func showIncomingMessage(id: String, sender: String, body: String, conversationID: String) {
let content = UNMutableNotificationContent()
content.title = sender
content.body = body
content.sound = .default
content.threadIdentifier = conversationID
content.userInfo = ["conversation": conversationID]
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// System notifications are normally hidden while an app is in the
// foreground. Explicitly request the same banner/list/sound treatment
// used when Luma is backgrounded.
[.banner, .list, .sound]
}
}
@@ -0,0 +1,405 @@
import Foundation
struct PhoneWatchSnapshot: Codable {
struct Chat: Codable {
let jid: String
let name: String
let unread: Int
let messages: [Message]
}
struct Message: Codable, Identifiable {
let id: String
let body: String
let timestamp: Date
let outgoing: Bool
let encrypted: Bool
}
let chats: [Chat]
}
struct WatchVoiceMessage: Sendable {
let transferID: String
let jid: String
let filename: String
let duration: TimeInterval?
let data: Data
var stableMessageID: String {
"watch-\(transferID)"
}
}
#if os(iOS) && canImport(WatchConnectivity)
import WatchConnectivity
final class PhoneWatchBridge: NSObject, WCSessionDelegate {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
didSet { deliverPendingVoiceMessages() }
}
private let session: WCSession?
private let snapshotQueue = DispatchQueue(
label: "app.luma.watch-snapshot",
qos: .utility
)
private var latestSnapshot: Data?
private var pendingVoiceMessages: [WatchVoiceMessage] = []
private var deliveredVoiceTransferIDs: Set<String> = []
override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
session?.delegate = self
session?.activate()
}
func update(conversations: [Conversation], messages: [ChatMessage]) {
guard let session else { return }
// Copy-on-write snapshots are cheap here; the filtering, sorting and
// JSON encoding are not. Keep that work away from SwiftUI's main run
// loop, especially immediately after a MAM page is committed.
snapshotQueue.async { [weak self, weak session] in
guard let self,
let session,
let data = Self.makeSnapshotData(
conversations: conversations,
messages: messages
),
data != self.latestSnapshot else { return }
self.latestSnapshot = data
self.publishLatestSnapshot(using: session)
}
}
private static func makeSnapshotData(
conversations: [Conversation],
messages: [ChatMessage]
) -> Data? {
let visibleConversations = Array(conversations.prefix(20))
let visibleConversationIDs = Set(visibleConversations.map(\.id))
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
// Keep a bounded, chronologically sorted window. MAM can append an old
// overlapping stanza after a newer live message, so array order alone
// cannot identify the latest messages reliably.
for message in messages where visibleConversationIDs.contains(message.conversationID) {
var recent = recentMessagesByConversation[message.conversationID] ?? []
let insertionIndex = Self.insertionIndex(for: message, in: recent)
recent.insert(message, at: insertionIndex)
if recent.count > 20 {
recent.removeFirst(recent.count - 20)
}
recentMessagesByConversation[message.conversationID] = recent
}
let chats = visibleConversations.map { conversation in
PhoneWatchSnapshot.Chat(
jid: conversation.jid,
name: conversation.displayName,
unread: conversation.unreadCount,
messages: (recentMessagesByConversation[conversation.id] ?? [])
.map {
PhoneWatchSnapshot.Message(
id: $0.id,
body: $0.previewText,
timestamp: $0.timestamp,
outgoing: $0.direction == .outgoing,
encrypted: $0.security == .omemo
)
}
)
}
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
}
private static func insertionIndex(
for message: ChatMessage,
in sortedMessages: [ChatMessage]
) -> Int {
var lowerBound = 0
var upperBound = sortedMessages.count
while lowerBound < upperBound {
let middle = lowerBound + (upperBound - lowerBound) / 2
let existing = sortedMessages[middle]
let existingComesFirst = existing.timestamp < message.timestamp
|| (existing.timestamp == message.timestamp && existing.id < message.id)
if existingComesFirst {
lowerBound = middle + 1
} else {
upperBound = middle
}
}
return lowerBound
}
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated else { return }
snapshotQueue.async { [weak self, weak session] in
guard let self, let session else { return }
self.publishLatestSnapshot(using: session)
}
}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
consume(message)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
consume(userInfo)
}
func session(_ session: WCSession, didReceive file: WCSessionFile) {
guard let metadata = file.metadata,
metadata["action"] as? String == "voice",
let transferID = metadata["transferID"] as? String,
UUID(uuidString: transferID) != nil,
let jid = metadata["jid"] as? String,
!jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
// WCSession owns this URL only for the duration of the callback.
// Copy the bytes before handing the recording to the async upload
// pipeline so the temporary file cannot disappear underneath it.
let data = try Data(contentsOf: file.fileURL)
guard !data.isEmpty else { throw WatchVoiceBridgeError.emptyRecording }
let duration = (metadata["duration"] as? NSNumber)?.doubleValue
let filename = (metadata["filename"] as? String).flatMap { $0.isEmpty ? nil : $0 }
?? file.fileURL.lastPathComponent
let message = WatchVoiceMessage(
transferID: transferID,
jid: jid,
filename: filename,
duration: duration,
data: data
)
try Self.persistVoiceMessage(message)
DispatchQueue.main.async { [weak self] in
self?.deliverVoiceMessage(message)
}
} catch {
reportVoiceResult(
transferID: transferID,
success: false,
error: "iPhone не смог прочитать запись: \(error.localizedDescription)",
jid: jid
)
}
}
func reportVoiceResult(
transferID: String,
success: Bool,
error: String? = nil,
jid explicitJID: String? = nil
) {
guard let session else { return }
let jid = explicitJID ?? Self.persistedVoiceJID(for: transferID)
Self.removePersistedVoiceMessage(transferID: transferID)
deliveredVoiceTransferIDs.remove(transferID)
var payload: [String: Any] = [
"action": "voiceResult",
"transferID": transferID,
"success": success
]
if let jid, !jid.isEmpty {
payload["jid"] = jid
}
if let error, !error.isEmpty {
payload["error"] = error
}
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
}
private func consume(_ payload: [String: Any]) {
guard payload["action"] as? String == "reply",
let jid = payload["jid"] as? String,
let text = payload["text"] as? String,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
onReply?(jid, text)
}
private func publishLatestSnapshot(using session: WCSession) {
guard let latestSnapshot else { return }
try? session.updateApplicationContext(["snapshot": latestSnapshot])
}
private func deliverVoiceMessage(_ message: WatchVoiceMessage) {
if let onVoiceMessage {
guard deliveredVoiceTransferIDs.insert(message.transferID).inserted else { return }
onVoiceMessage(message)
} else if !pendingVoiceMessages.contains(where: { $0.transferID == message.transferID }) {
pendingVoiceMessages.append(message)
}
}
private func deliverPendingVoiceMessages() {
guard let onVoiceMessage, !pendingVoiceMessages.isEmpty else { return }
let pending = pendingVoiceMessages
pendingVoiceMessages.removeAll()
for message in pending where deliveredVoiceTransferIDs.insert(message.transferID).inserted {
onVoiceMessage(message)
}
}
private static func persistVoiceMessage(_ message: WatchVoiceMessage) throws {
let directory = try watchVoiceOutboxDirectory()
let audioFilename = "\(message.transferID).m4a"
let audioURL = directory.appendingPathComponent(audioFilename)
let metadataURL = directory.appendingPathComponent("\(message.transferID).json")
let stored = StoredWatchVoiceMessage(
transferID: message.transferID,
jid: message.jid,
filename: message.filename,
duration: message.duration,
audioFilename: audioFilename,
createdAt: Date()
)
do {
try message.data.write(to: audioURL, options: [.atomic])
try JSONEncoder().encode(stored).write(to: metadataURL, options: [.atomic])
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: audioURL.path
)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: metadataURL.path
)
} catch {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: metadataURL)
throw error
}
}
private static func loadPersistedVoiceMessages() -> [WatchVoiceMessage] {
guard let directory = try? watchVoiceOutboxDirectory() else { return [] }
let metadataURLs = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)) ?? []
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
return metadataURLs
.filter { $0.pathExtension.lowercased() == "json" }
.compactMap { metadataURL in
guard let metadataData = try? Data(contentsOf: metadataURL),
let stored = try? JSONDecoder().decode(
StoredWatchVoiceMessage.self,
from: metadataData
),
stored.createdAt >= expiration else {
removePersistedVoiceMessage(
transferID: metadataURL.deletingPathExtension().lastPathComponent
)
return nil
}
let audioURL = directory.appendingPathComponent(stored.audioFilename)
guard let data = try? Data(contentsOf: audioURL), !data.isEmpty else {
removePersistedVoiceMessage(transferID: stored.transferID)
return nil
}
return WatchVoiceMessage(
transferID: stored.transferID,
jid: stored.jid,
filename: stored.filename,
duration: stored.duration,
data: data
)
}
.sorted { $0.transferID < $1.transferID }
}
private static func persistedVoiceJID(for transferID: String) -> String? {
guard let directory = try? watchVoiceOutboxDirectory(),
let data = try? Data(contentsOf: directory.appendingPathComponent("\(transferID).json")),
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) else {
return nil
}
return stored.jid
}
private static func removePersistedVoiceMessage(transferID: String) {
guard UUID(uuidString: transferID) != nil,
let directory = try? watchVoiceOutboxDirectory() else { return }
let metadataURL = directory.appendingPathComponent("\(transferID).json")
if let data = try? Data(contentsOf: metadataURL),
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) {
try? FileManager.default.removeItem(
at: directory.appendingPathComponent(stored.audioFilename)
)
} else {
try? FileManager.default.removeItem(
at: directory.appendingPathComponent("\(transferID).m4a")
)
}
try? FileManager.default.removeItem(at: metadataURL)
}
private static func watchVoiceOutboxDirectory() throws -> URL {
let base = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
let directory = base.appendingPathComponent("Luma/WatchVoiceOutbox", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
}
private struct StoredWatchVoiceMessage: Codable {
let transferID: String
let jid: String
let filename: String
let duration: TimeInterval?
let audioFilename: String
let createdAt: Date
}
#else
final class PhoneWatchBridge {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)?
func update(conversations: [Conversation], messages: [ChatMessage]) {}
func reportVoiceResult(transferID: String, success: Bool, error: String? = nil) {}
}
#endif
private extension JSONEncoder {
static var watchEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}
}
private enum WatchVoiceBridgeError: LocalizedError {
case emptyRecording
var errorDescription: String? {
"Файл голосового сообщения пуст."
}
}
@@ -0,0 +1,930 @@
@preconcurrency import AVFoundation
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
@MainActor
final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate {
struct Recording {
let url: URL
let duration: TimeInterval
}
@Published private(set) var isPrepared = false
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var isUsingFrontCamera = true
@Published private(set) var isMicrophoneMuted = false
let session = AVCaptureSession()
var onFinished: ((Result<Recording, Error>) -> Void)?
var hasRequiredAuthorization: Bool {
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
&& AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
private let movieOutput = AVCaptureMovieFileOutput()
private let sessionQueue = DispatchQueue(
label: "app.luma.video-note.capture-session",
qos: .userInitiated
)
private let fileInspector = VideoNoteFileInspector()
private var cameraInput: AVCaptureDeviceInput?
private var microphoneInput: AVCaptureDeviceInput?
private var timer: Timer?
private var startedAt: Date?
private var discardCurrentRecording = false
private var stopRequested = false
private var requestedMinimumDuration: TimeInterval = 0
private var sessionGeneration = UUID()
private var lifecycle: VideoNoteRecordingLifecycle = .idle
private var activeRecordingURL: URL?
private var stopRequestTask: Task<Void, Never>?
private var startupTimeoutTask: Task<Void, Never>?
private var finalizationTimeoutTask: Task<Void, Never>?
private var fileValidationTask: Task<Void, Never>?
private struct CaptureGraph {
let cameraInput: AVCaptureDeviceInput
let microphoneInput: AVCaptureDeviceInput
let usesFrontCamera: Bool
}
private struct MovieOutputSnapshot: Sendable {
let isRecording: Bool
let recordedDuration: TimeInterval
}
func prepare() async throws {
try await waitForPreviousRecordingToFinish()
if lifecycle == .prepared, isPrepared { return }
guard lifecycle == .idle, !isPrepared else {
throw VideoNoteRecorderError.recordingBusy
}
let generation = UUID()
sessionGeneration = generation
await waitForPendingSessionOperations()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .video) else {
throw VideoNoteRecorderError.cameraPermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .audio) else {
throw VideoNoteRecorderError.microphonePermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
var didPrepare = false
defer {
if !didPrepare {
performShutdown()
}
}
#if os(iOS)
try AVAudioSession.sharedInstance().setCategory(
.playAndRecord,
mode: .videoRecording,
options: [.defaultToSpeaker, .allowBluetoothHFP]
)
try AVAudioSession.sharedInstance().setActive(true)
#endif
let graph = try await configureCaptureGraph()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
cameraInput = graph.cameraInput
microphoneInput = graph.microphoneInput
isUsingFrontCamera = graph.usesFrontCamera
configureVideoConnection()
try await startCaptureSession(generation: generation)
isPrepared = true
lifecycle = .prepared
didPrepare = true
}
func start() throws {
guard lifecycle == .prepared,
isPrepared,
session.isRunning else { throw VideoNoteRecorderError.notPrepared }
guard !movieOutput.isRecording,
activeRecordingURL == nil else {
throw VideoNoteRecorderError.recordingBusy
}
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov")
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
activeRecordingURL = url
lifecycle = .starting
startedAt = nil
elapsed = 0
isRecording = true
let output = movieOutput
let delegate = self
sessionQueue.async {
output.startRecording(to: url, recordingDelegate: delegate)
}
scheduleStartupTimeout(for: url)
}
func stop() {
requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration)
}
func stop(afterMinimumDuration minimumDuration: TimeInterval) async {
requestStop(afterMinimumDuration: minimumDuration)
}
func cancel() {
discardCurrentRecording = true
stopRequested = true
requestedMinimumDuration = 0
switch lifecycle {
case .starting, .recording:
lifecycle = .stopping
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
let url = self.activeRecordingURL else { return }
self.scheduleFinalizationTimeout(for: url)
}
case .stopping:
break
case .idle, .prepared:
performShutdown()
}
}
func switchCamera() async throws {
#if os(iOS)
guard isPrepared,
lifecycle == .prepared,
let currentInput = cameraInput else {
throw VideoNoteRecorderError.notPrepared
}
let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front
guard let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: nextPosition
) else {
throw VideoNoteRecorderError.alternateCameraUnavailable
}
let nextInput = try AVCaptureDeviceInput(device: camera)
let session = session
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
sessionQueue.async {
session.beginConfiguration()
session.removeInput(currentInput)
guard session.canAddInput(nextInput) else {
if session.canAddInput(currentInput) {
session.addInput(currentInput)
}
session.commitConfiguration()
continuation.resume(throwing: VideoNoteRecorderError.configurationFailed)
return
}
session.addInput(nextInput)
session.commitConfiguration()
continuation.resume()
}
}
cameraInput = nextInput
isUsingFrontCamera = nextPosition == .front
configureVideoConnection()
#else
throw VideoNoteRecorderError.alternateCameraUnavailable
#endif
}
func toggleMicrophoneMuted() {
guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return }
isMicrophoneMuted.toggle()
connection.isEnabled = !isMicrophoneMuted
}
func shutdown() {
if lifecycle.isBusy || movieOutput.isRecording {
cancel()
return
}
performShutdown()
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didStartRecordingTo fileURL: URL,
from connections: [AVCaptureConnection]
) {
Task { @MainActor [weak self] in
self?.recordingDidStart(url: fileURL)
}
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?
) {
Task { @MainActor [weak self] in
self?.recordingDidFinish(url: outputFileURL, error: error)
}
}
private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) {
guard isRecording,
lifecycle == .starting || lifecycle == .recording else { return }
stopRequested = true
requestedMinimumDuration = max(
requestedMinimumDuration,
max(0, minimumDuration)
)
scheduleStopAfterActualStartIfNeeded()
}
private func recordingDidStart(url: URL) {
guard VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
), isRecording,
lifecycle == .starting || lifecycle == .stopping else { return }
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
if startedAt == nil {
startedAt = Date()
}
if lifecycle == .stopping {
// Cancellation can be requested while startRecording is still
// crossing the capture queue. If the first stop observed
// isRecording == false, stop again after the real didStart rather
// than leaving an orphaned capture running until the watchdog.
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
return
}
lifecycle = .recording
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = min(
VideoNoteStopPolicy.maximumCaptureDuration,
Date().timeIntervalSince(startedAt)
)
}
}
scheduleStopAfterActualStartIfNeeded()
}
private func scheduleStopAfterActualStartIfNeeded() {
guard stopRequested,
lifecycle == .recording,
startedAt != nil,
let url = activeRecordingURL else { return }
stopRequestTask?.cancel()
let minimumDuration = requestedMinimumDuration
let mediaDeadline = Date().addingTimeInterval(
VideoNoteStopPolicy.recordedDurationWaitTimeout
)
stopRequestTask = Task { @MainActor [weak self] in
while true {
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
let snapshot = await self.movieOutputSnapshot()
guard !Task.isCancelled,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
if !snapshot.isRecording {
// AVFoundation stopped without the user's stop reaching
// the output. Wait for didFinish, but never let this state
// remain without a finalization watchdog.
self.lifecycle = .stopping
self.scheduleFinalizationTimeout(for: url)
return
}
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
recordedDuration: snapshot.recordedDuration,
minimumDuration: minimumDuration
)
if remaining <= 0 || Date() >= mediaDeadline { break }
do {
try await Task.sleep(
nanoseconds: min(
VideoNoteStopPolicy.recordedDurationPollNanoseconds,
UInt64((remaining * 1_000_000_000).rounded(.up))
)
)
} catch {
return
}
}
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func recordingDidFinish(url: URL, error: Error?) {
guard VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
) else {
// A delayed callback from an earlier attempt must not tear down a
// newer capture graph. Do not delete it here: macOS can spell the
// same temporary URL through a filesystem alias, and preserving a
// doubtful callback is safer than deleting the active movie.
return
}
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
timer?.invalidate()
timer = nil
isRecording = false
lifecycle = .stopping
let fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested
if shouldDiscard {
try? FileManager.default.removeItem(at: url)
finishAndReset(nil)
return
}
fileValidationTask?.cancel()
fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return }
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.fileValidationTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
return
}
if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: wasStopRequested,
error: error,
recordedDuration: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
self.finishAndReset(.success(Recording(
url: url,
duration: inspection.duration
)))
return
}
try? FileManager.default.removeItem(at: url)
let failure: Error
if !wasStopRequested,
!VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: false,
error: error,
recordedDuration: inspection?.duration ?? fallbackDuration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
) {
failure = VideoNoteRecorderError.recordingInterrupted
} else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error {
failure = error
} else {
failure = VideoNoteRecorderError.emptyRecording
}
self.finishAndReset(.failure(failure))
}
}
private var measuredWallClockDuration: TimeInterval {
guard let startedAt else { return max(0, elapsed) }
return max(elapsed, Date().timeIntervalSince(startedAt))
}
private func stopMovieOutput() async {
let output = movieOutput
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
continuation.resume()
}
}
}
private func movieOutputSnapshot() async -> MovieOutputSnapshot {
let output = movieOutput
return await withCheckedContinuation { continuation in
sessionQueue.async {
let seconds = CMTimeGetSeconds(output.recordedDuration)
continuation.resume(returning: MovieOutputSnapshot(
isRecording: output.isRecording,
recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
))
}
}
}
private func flushCaptureSessionForFinalization() async {
let output = movieOutput
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
if session.isRunning {
session.stopRunning()
}
continuation.resume()
}
}
}
private func scheduleStartupTimeout(for url: URL) {
startupTimeoutTask?.cancel()
startupTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .starting,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func scheduleFinalizationTimeout(for url: URL) {
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
// A few macOS camera drivers stop the file output but delay its
// delegate callback until the capture session itself is flushed.
// This is recovery-only; the normal path still waits for
// didFinishRecording before touching the session.
await self.flushCaptureSessionForFinalization()
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
let fallbackDuration = self.measuredWallClockDuration
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.finalizationTimeoutTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
} else if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: self.stopRequested,
error: nil,
recordedDuration: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
// Some macOS camera drivers finalize the .mov but omit the
// delegate callback. A playable file is more authoritative
// than the missing callback and remains safe to send.
self.finishAndReset(.success(Recording(
url: url,
duration: inspection.duration
)))
} else if !self.stopRequested {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted))
} else {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut))
}
}
}
private func finishAndReset(_ result: Result<Recording, Error>?) {
fileValidationTask = nil
let completion = onFinished
performShutdown()
if let result {
completion?(result)
}
}
private func performShutdown() {
sessionGeneration = UUID()
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
fileValidationTask?.cancel()
fileValidationTask = nil
timer?.invalidate()
timer = nil
resetCaptureGraph()
lifecycle = .idle
activeRecordingURL = nil
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
startedAt = nil
elapsed = 0
isPrepared = false
isRecording = false
isUsingFrontCamera = true
isMicrophoneMuted = false
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
private func waitForPreviousRecordingToFinish() async throws {
guard lifecycle != .starting, lifecycle != .recording else {
throw VideoNoteRecorderError.recordingBusy
}
let deadline = Date().addingTimeInterval(10)
while lifecycle == .stopping, Date() < deadline {
try await Task.sleep(nanoseconds: 25_000_000)
}
guard lifecycle != .stopping else {
throw VideoNoteRecorderError.finalizationTimedOut
}
}
private func configureVideoConnection() {
guard let connection = movieOutput.connection(with: .video) else { return }
#if os(iOS)
configureInteroperableVideoCodec(for: connection)
#endif
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = isUsingFrontCamera
}
#if os(iOS)
if connection.isVideoOrientationSupported {
connection.videoOrientation = Self.currentVideoOrientation
}
#endif
}
#if os(iOS)
private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
movieOutput.setOutputSettings(
[AVVideoCodecKey: AVVideoCodecType.h264],
for: connection
)
}
#endif
private func resetCaptureGraph() {
let session = session
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return }
session.beginConfiguration()
session.inputs.forEach { session.removeInput($0) }
session.outputs.forEach { session.removeOutput($0) }
session.commitConfiguration()
}
cameraInput = nil
microphoneInput = nil
}
private func waitForPendingSessionOperations() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
continuation.resume()
}
}
}
private func configureCaptureGraph() async throws -> CaptureGraph {
let session = session
let movieOutput = movieOutput
return try await withCheckedThrowingContinuation { continuation in
sessionQueue.async {
session.beginConfiguration()
do {
session.sessionPreset = session.canSetSessionPreset(.vga640x480)
? .vga640x480
: .medium
#if os(iOS)
let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .front
) ?? AVCaptureDevice.default(for: .video)
#else
let camera = AVCaptureDevice.default(for: .video)
#endif
guard let camera else {
throw VideoNoteRecorderError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(cameraInput)
guard let microphone = AVCaptureDevice.default(for: .audio) else {
throw VideoNoteRecorderError.microphoneUnavailable
}
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
guard session.canAddInput(microphoneInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(microphoneInput)
guard session.canAddOutput(movieOutput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addOutput(movieOutput)
movieOutput.maxRecordedDuration = CMTime(
seconds: VideoNoteStopPolicy.maximumCaptureDuration,
preferredTimescale: 600
)
session.commitConfiguration()
continuation.resume(returning: CaptureGraph(
cameraInput: cameraInput,
microphoneInput: microphoneInput,
usesFrontCamera: camera.position == .front
))
} catch {
session.commitConfiguration()
continuation.resume(throwing: error)
}
}
}
}
private func startCaptureSession(generation: UUID) async throws {
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if !session.isRunning {
session.startRunning()
}
continuation.resume()
}
}
guard !Task.isCancelled,
sessionGeneration == generation,
session.isRunning else {
throw CancellationError()
}
}
private static func requestAccess(for mediaType: AVMediaType) async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: mediaType)
@unknown default:
return false
}
}
}
private actor VideoNoteFileInspector {
struct Inspection: Sendable {
let duration: TimeInterval
}
func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
for delay in retryDelays {
if delay > 0 {
try? await Task.sleep(nanoseconds: delay)
}
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
values.isRegularFile == true,
let size = values.fileSize,
size > 1_024 else { continue }
let asset = AVURLAsset(url: url)
guard let duration = try? await asset.load(.duration),
duration.seconds.isFinite,
duration.seconds > 0.04,
let tracks = try? await asset.loadTracks(withMediaType: .video),
!tracks.isEmpty else { continue }
return Inspection(duration: duration.seconds)
}
// Keep a non-empty file only when AVFoundation reported enough wall
// clock time to make a useful fallback. This branch covers a small set
// of macOS camera drivers that delay movie metadata longer than the
// delegate callback itself.
guard fallbackDuration > 0.25,
let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
let size = values.fileSize,
size > 16_384 else { return nil }
return Inspection(duration: fallbackDuration)
}
}
enum VideoNoteStopPolicy {
static let minimumCaptureDuration: TimeInterval = 1.2
static let minimumValidFileDuration: TimeInterval = 0.8
static let maximumCaptureDuration: TimeInterval = 60
static let recordedDurationWaitTimeout: TimeInterval = 5
static let recordedDurationPollNanoseconds: UInt64 = 80_000_000
static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000
static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000
static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000
static func remainingRecordedDuration(
recordedDuration: TimeInterval,
minimumDuration: TimeInterval
) -> TimeInterval {
max(0, max(0, minimumDuration) - max(0, recordedDuration))
}
static func isValidFinalDuration(_ duration: TimeInterval) -> Bool {
duration.isFinite && duration >= minimumValidFileDuration
}
}
#if os(iOS)
private extension VideoNoteRecorder {
static var currentVideoOrientation: AVCaptureVideoOrientation {
switch UIDevice.current.orientation {
case .portraitUpsideDown:
return .portraitUpsideDown
case .landscapeLeft:
return .landscapeRight
case .landscapeRight:
return .landscapeLeft
default:
return .portrait
}
}
}
#endif
private enum VideoNoteRecorderError: LocalizedError {
case cameraPermissionDenied
case microphonePermissionDenied
case cameraUnavailable
case alternateCameraUnavailable
case microphoneUnavailable
case configurationFailed
case notPrepared
case recordingBusy
case recordingInterrupted
case finalizationTimedOut
case emptyRecording
var errorDescription: String? {
switch self {
case .cameraPermissionDenied:
return "Разрешите Luma доступ к камере в системных настройках."
case .microphonePermissionDenied:
return "Разрешите Luma доступ к микрофону в системных настройках."
case .cameraUnavailable:
return "Камера недоступна."
case .alternateCameraUnavailable:
return "Вторая камера недоступна."
case .microphoneUnavailable:
return "Микрофон недоступен."
case .configurationFailed:
return "Не удалось настроить запись видеосообщения."
case .notPrepared:
return "Камера ещё не готова."
case .recordingBusy:
return "Предыдущая запись ещё завершается. Подождите секунду и повторите."
case .recordingInterrupted:
return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
case .finalizationTimedOut:
return "Камера не смогла завершить видеофайл. Повторите запись."
case .emptyRecording:
return "Камера создала пустой или нечитаемый видеофайл. Повторите запись."
}
}
}