369 lines
16 KiB
Swift
369 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|