AutoCat/AutoCat/Controllers/RecordsController.swift

369 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
import AVFoundation
import RealmSwift
import Intents
import CoreSpotlight
import MobileCoreServices
import os.log
import PKHUD
import AutoCatCore
class RecordsController: UIViewController, UITableViewDelegate {
@IBOutlet weak var tableView: UITableView!
@Service var settingsService: SettingsServiceProtocol
var recorder: Recorder?
var addButton: UIBarButtonItem!
var audioSessionObserver: NSObjectProtocol?
var recordsDataSource: RealmSectionedDataSource<AudioRecord, AudioRecordCell>!
let validLetters = Constants.pnLettersMap.keys.map(String.init)
override func viewDidLoad() {
super.viewDidLoad()
guard let realm = try? Realm() else { return }
self.addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(onAddVoiceRecord(_:)))
self.navigationItem.rightBarButtonItem = self.addButton
self.recorder = Recorder()
DispatchQueue.main.async {
self.recordsDataSource = RealmSectionedDataSource(table: self.tableView, data: realm.objects(AudioRecord.self)
.sorted(byKeyPath: "addedDate", ascending: false))
}
self.tableView.delegate = self
}
func donateUserActivity() {
let activityId = "pro.aliencat.autocat.addVoiceRecord"
let activity = NSUserActivity(activityType: activityId)
activity.persistentIdentifier = activityId
activity.isEligibleForSearch = true
activity.isEligibleForPrediction = true
activity.title = NSLocalizedString("Add new audio record", comment: "")
activity.suggestedInvocationPhrase = "Запиши номер"
let attributes = CSSearchableItemAttributeSet()
attributes.contentType = kUTTypeItem as String
attributes.contentDescription = NSLocalizedString("Add new plate number via audio recording", comment: "")
activity.contentAttributeSet = attributes
self.userActivity = activity
activity.becomeCurrent()
}
func stopRecording() {
self.recorder?.stopRecording()
}
// MARK: - Bar button handlers
@objc func onAddVoiceRecord(_ sender: UIBarButtonItem) {
guard let recorder = self.recorder else {
HUD.flash(.labeledError(title: nil, subtitle: "Audio recorder is not available"))
return
}
self.addButton.isEnabled = false
var alert: UIAlertController?
var url: URL!
Task { @MainActor in
do {
let event = try await RxLocationManager.requestCurrentLocation()
try await recorder.requestPermissions()
await makeStartSoundIfNeeded()
#if targetEnvironment(macCatalyst) || targetEnvironment(simulator)
DispatchQueue.main.async {
alert = self.showRecordingAlert()
}
#else
if let observer = self.audioSessionObserver {
NotificationCenter.default.removeObserver(observer, name: AVAudioSession.routeChangeNotification, object: nil)
}
self.audioSessionObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { notification in
guard let dict = notification.userInfo as? [String: Any],
let reasonInt = dict["AVAudioSessionRouteChangeReasonKey"] as? NSNumber,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonInt.uintValue),
let session = notification.object as? AVAudioSession else { return }
if reason == .categoryChange && session.category == .playAndRecord {
DispatchQueue.main.async {
alert = self.showRecordingAlert()
}
}
}
#endif
let date = Date()
let fileName = "recording-\(date.timeIntervalSince1970).m4a"
url = try FileManager.default.url(for: fileName, in: "recordings")
let text = try await recorder.startRecording(to: url)
let asset = AVURLAsset(url: url)
let duration = TimeInterval(CMTimeGetSeconds(asset.duration))
let record = AudioRecordDto(path: url.lastPathComponent,
number: self.getPlateNumber(from: text),
raw: text,
duration: duration,
event: event)
let realm = try await Realm()
try realm.write {
realm.add(AudioRecord(dto: record))
}
alert?.dismiss(animated: true)
self.addButton.isEnabled = true
} catch {
if let alert = alert {
alert.dismiss(animated: true) {
HUD.show(error: error)
}
} else {
HUD.show(error: error)
}
self.addButton.isEnabled = true
}
}
}
func showRecordingAlert() -> UIAlertController {
let alert = UIAlertController(title: NSLocalizedString("Recording...", comment: ""), message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: { _ in self.recorder?.cancelRecording() }))
alert.addAction(UIAlertAction(title: NSLocalizedString("Done", comment: ""), style: .default, handler: { _ in self.recorder?.stopRecording() }))
self.present(alert, animated: true)
return alert
}
// MARK: - Processing
func getPlateNumber(from recognizedText: String) -> String? {
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
}
func makeStartSoundIfNeeded() async {
guard settingsService.recordBeep else {
return
}
return await withCheckedContinuation { continuation in
var soundId = SystemSoundID()
let url = URL(fileURLWithPath: "/System/Library/Audio/UISounds/short_double_high.caf")
AudioServicesCreateSystemSoundID(url as CFURL, &soundId)
AudioServicesPlaySystemSoundWithCompletion(soundId) {
continuation.resume()
}
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
let record = self.recordsDataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
let check = UIAction(title: NSLocalizedString("Check", comment: ""), image: UIImage(systemName: "eye")) { action in
if let number = record.number {
self.check(number: number, event: record.event)
}
}
let delete = UIAction(title: NSLocalizedString("Delete", comment: ""), image: UIImage(systemName: "trash"), attributes: .destructive) { action in
self.delete(record: record)
}
let share = UIAction(title: NSLocalizedString("Share", comment: ""), image: UIImage(systemName: "square.and.arrow.up")) { action in
self.share(record: record)
}
let showText = UIAction(title: NSLocalizedString("Show recognized text", comment: ""), image: UIImage(systemName: "textformat")) { action in
self.showAlert(title: NSLocalizedString("Recognized text", comment: ""), message: record.rawText)
}
let showMap = UIAction(title: NSLocalizedString("Show on map", comment: ""), image: UIImage(systemName: "mappin.and.ellipse")) { action in
self.showOnMap(record)
}
let edit = UIAction(title: NSLocalizedString("Edit plate number", comment: ""), image: UIImage(systemName: "pencil")) { action in
self.edit(record: record)
}
var actions = [edit, showText, showMap, share, delete]
if record.number != nil {
actions.insert(check, at: 0)
}
return UIMenu(title: NSLocalizedString("Actions", comment: ""), children: actions)
}
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let cell = tableView.cellForRow(at: indexPath) else { return nil }
let record = self.recordsDataSource.item(at: indexPath)
let check = UIContextualAction(style: .normal, title: NSLocalizedString("Check", comment: "")) { action, view, completion in
if let number = record.number {
self.check(number: number, event: record.event)
}
completion(true)
}
check.backgroundColor = .systemGray2
check.image = UIImage(systemName: "eye")
let action = UIContextualAction(style: .normal, title: NSLocalizedString("Action", comment: "")) { action, view, completion in
self.moreActions(for: record, cell: cell)
completion(true)
}
action.backgroundColor = .systemGray2
action.image = UIImage(systemName: "ellipsis" /*"square.and.arrow.up"*/)
let delete = UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "")) { action, view, completion in
self.delete(record: record)
completion(true)
}
delete.image = UIImage(systemName: "trash")
let actions = record.number == nil ? [delete, action] : [delete, check, action]
let configuration = UISwipeActionsConfiguration(actions: actions)
configuration.performsFirstActionWithFullSwipe = false
return configuration
}
func moreActions(for record: AudioRecordDto, cell: UITableViewCell) {
let sheet = UIAlertController(title: NSLocalizedString("More actions", comment: ""), message: nil, preferredStyle: .actionSheet)
let cancel = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in sheet.dismiss(animated: true, completion: nil) }
let share = UIAlertAction(title: NSLocalizedString("Share", comment: ""), style: .default) { _ in
self.share(record: record)
}
let showText = UIAlertAction(title: NSLocalizedString("Show recognized text", comment: ""), style: .default) { action in
self.showAlert(title: NSLocalizedString("Recognized text", comment: ""), message: record.rawText)
}
let editNumber = UIAlertAction(title: NSLocalizedString("Edit plate number", comment: ""), style: .default) { action in
self.edit(record: record)
}
let showOnMap = UIAlertAction(title: NSLocalizedString("Show on map", comment: ""), style: .default) { action in
self.showOnMap(record)
}
sheet.addAction(editNumber)
sheet.addAction(showText)
if record.event != nil {
sheet.addAction(showOnMap)
}
sheet.addAction(share)
sheet.addAction(cancel)
sheet.popoverPresentationController?.sourceView = cell
sheet.popoverPresentationController?.sourceRect = cell.frame
self.present(sheet, animated: true, completion: nil)
}
func check(number: String, event: VehicleEventDto?) {
// TODO: Implement checking number without quick action
self.tabBarController?.selectedIndex = 0
}
func edit(record: AudioRecordDto) {
let alert = UIAlertController(title: NSLocalizedString("Edit plate number", comment: ""), message: nil, preferredStyle: .alert)
let done = UIAlertAction(title: NSLocalizedString("Done", comment: ""), style: .default) { action in
guard let tf = alert.textFields?.first else { return }
if let realm = try? Realm(), let realmRecord = realm.object(ofType: AudioRecord.self, forPrimaryKey: record.path) {
try? realm.write {
realmRecord.number = tf.text?.uppercased()
}
}
}
alert.addAction(done)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: { action in
alert.dismiss(animated: true)
}))
alert.addTextField { tf in
tf.text = record.number ?? record.rawText.replacingOccurrences(of: " ", with: "")
NotificationCenter.default.addObserver(forName: UITextField.textDidChangeNotification, object: tf, queue: nil) { _ in
DispatchQueue.main.async {
done.isEnabled = self.valid(number: tf.text?.uppercased() ?? "")
}
}
}
self.present(alert, animated: true)
}
func delete(record: AudioRecordDto) {
do {
if let realm = try? Realm(), let realmRecord = realm.object(ofType: AudioRecord.self, forPrimaryKey: record.path) {
try realm.write {
realm.delete(realmRecord)
}
}
} catch {
print(error)
}
}
func share(record: AudioRecordDto) {
do {
let url = try FileManager.default.url(for: record.path, in: "recordings")
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
self.present(controller, animated: true)
} catch {
print("Error sharing audio record: \(error.localizedDescription)")
HUD.show(error: error)
}
}
func showOnMap(_ record: AudioRecordDto) {
let controller = ShowEventController()
controller.event = record.event
controller.hidesBottomBarWhenPushed = true
self.navigationController?.pushViewController(controller, animated: true)
}
}