// // VehicleRecordService.swift // AutoCatCore // // Created by Selim Mustafaev on 16.03.2025. // Copyright © 2025 Selim Mustafaev. All rights reserved. // import Foundation public actor VehicleRecordService { let recordService: AudioRecordServiceProtocol let locationService: LocationServiceProtocol let settingsService: SettingsServiceProtocol let validLetters = Constants.pnLettersMap.keys.map(String.init) var url: URL? var date = Date() var location: VehicleEventDto? @AutoCancellable var locationTask: Task? public init( recordService: AudioRecordServiceProtocol, locationService: LocationServiceProtocol, settingsService: SettingsServiceProtocol ) { self.recordService = recordService self.locationService = locationService self.settingsService = settingsService } func getPlateNumber(from recognizedText: String?) -> String? { guard let recognizedText else { return nil } let trimmed = recognizedText .replacingOccurrences(of: " ", with: "") .uppercased() .replacingOccurrences(of: "Ф", with: "В") .replacingOccurrences(of: "НОЛЬ", with: "0") .replacingOccurrences(of: "Э", with: "") var result = "" if let range = trimmed.range(of: #"\S\d\d\d\S\S\d\d\d?"#, options: .regularExpression) { result = String(trimmed[range]) } else if let range = trimmed.range(of: #"\S\S\S\d\d\d\d\d\d?"#, options: .regularExpression), settingsService.recognizeAlternativeOrder { let n = String(trimmed[range]) result = String(n.prefix(1)) + n.substring(with: 3..<6) + n.substring(with: 1..<3) + n.substring(from: 6) } else if let range = trimmed.range(of: #"\S\d\d\d\S\S"#, options: .regularExpression), settingsService.recognizeShortenedNumbers { result = String(trimmed[range]) + settingsService.defaultRegion } else if let range = trimmed.range(of: #"\S\S\S\d\d\d"#, options: .regularExpression), settingsService.recognizeAlternativeOrder && settingsService.recognizeShortenedNumbers { let n = String(trimmed[range]) result = String(n.prefix(1)) + n.substring(with: 3..<6) + n.substring(with: 1..<3) + settingsService.defaultRegion } if !result.isEmpty && valid(number: result) { return result } else { return nil } } func valid(number: String) -> Bool { guard number.count >= 8 else { return false } let first = String(number.prefix(1)) let second = number.substring(with: 4..<5) let third = number.substring(with: 5..<6) let digits = Int(number.substring(with: 1..<4)) let region = Int(number.substring(from: 6)) return self.validLetters.contains(first) && self.validLetters.contains(second) && self.validLetters.contains(third) && digits != nil && region != nil && region! < 1000 } } extension VehicleRecordService: VehicleRecordServiceProtocol { public func requestPermissionsIfNeeded() async { await recordService.requestRecordPermissions() await recordService.requestRecognitionAuthorization() } public func startRecording() async throws { date = Date() let fileName = "recording-\(date.timeIntervalSince1970).m4a" let url = try FileManager.default.url(for: fileName, in: Constants.audioRecordsFolder) self.url = url try await recordService.startRecording(to: url) locationTask = Task { location = try await locationService.getRecentLocation() } } public func stopRecording() async throws -> AudioRecordDto { guard let url else { await recordService.cancelRecording() throw VehicleRecordError.emptyUrl } await recordService.stopRecording() async let recognitionTask = recordService.recognizeText(from: url) async let durationTask = recordService.getDuration(from: url) let (text, duration) = await (recognitionTask, try? durationTask) locationTask?.cancel() locationTask = nil self.url = nil let record = AudioRecordDto( path: url.lastPathComponent, number: getPlateNumber(from: text), raw: text ?? "", duration: duration ?? 0, event: location ) return record } public func cancelRecording() async { await recordService.cancelRecording() locationTask?.cancel() locationTask = nil url = nil } }