Files
kontakte/Kontakte/ContactsViewModel.swift

227 lines
8.0 KiB
Swift

import Combine
@preconcurrency import Contacts
@preconcurrency import ContactsUI
import SwiftUI
@MainActor
final class ContactsViewModel: ObservableObject {
@Published private(set) var contacts: [CNContact] = []
@Published private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
@Published private(set) var lastError: String? = nil
@Published private(set) var favoriteContactIdentifiers: Set<String> = []
@Published private(set) var myCardIdentifier: String? = nil
@Published private(set) var supportsMetadataDSL = false
let store = CNContactStore()
func refresh() {
authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
if authorizationStatus == .authorized || authorizationStatus == .limited {
loadContacts()
} else {
contacts = []
lastError = nil
}
}
func clearError() {
lastError = nil
}
func isFavorite(_ contact: CNContact) -> Bool {
favoriteContactIdentifiers.contains(contact.identifier)
}
func isMyCard(_ contact: CNContact) -> Bool {
myCardIdentifier == contact.identifier
}
var myCard: CNContact? {
guard let myCardIdentifier else { return nil }
return contacts.first(where: { $0.identifier == myCardIdentifier })
}
func toggleFavorite(for contact: CNContact) {
updateMetadata(for: contact) { metadata in
metadata.isFavorite.toggle()
}
}
func toggleMyCard(for contact: CNContact) {
guard supportsMetadataDSL else {
lastError = String(localized: "contacts.metadata_unavailable")
return
}
do {
// Ensure no other contact remains tagged as My Card.
let containerID = store.defaultContainerIdentifier()
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactNoteKey as CNKeyDescriptor
]
let all = try store.unifiedContacts(
matching: CNContact.predicateForContactsInContainer(withIdentifier: containerID),
keysToFetch: keys
)
let request = CNSaveRequest()
for existing in all {
guard let mutable = existing.mutableCopy() as? CNMutableContact else { continue }
var metadata = ContactMetadataDSL.parse(note: mutable.note)
let shouldBeMyCard = existing.identifier == contact.identifier ? !metadata.isMyCard : false
if metadata.isMyCard != shouldBeMyCard {
metadata.isMyCard = shouldBeMyCard
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
request.update(mutable)
}
}
try store.execute(request)
refresh()
} catch {
lastError = error.localizedDescription
}
}
func delete(_ contact: CNContact) {
let mutableContact = contact.mutableCopy() as? CNMutableContact
guard let mutableContact else { return }
let request = CNSaveRequest()
request.delete(mutableContact)
do {
try store.execute(request)
refresh()
} catch {
lastError = error.localizedDescription
}
}
func requestAccess() {
store.requestAccess(for: .contacts) { [weak self] _, error in
DispatchQueue.main.async {
guard let self else { return }
if let error {
self.lastError = error.localizedDescription
}
self.refresh()
}
}
}
private func loadContacts() {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
let localStore = CNContactStore()
var fetched: [CNContact] = []
var fetchError: Error? = nil
do {
fetched = try Self.fetchContacts(using: localStore, keys: Self.baseKeys)
} catch {
fetchError = error
}
DispatchQueue.main.async {
if let fetchError {
self.contacts = []
self.lastError = fetchError.localizedDescription
self.favoriteContactIdentifiers = []
self.myCardIdentifier = nil
} else {
self.contacts = fetched
self.supportsMetadataDSL = false
self.lastError = nil
if self.supportsMetadataDSL {
self.favoriteContactIdentifiers = Set(
fetched
.filter { ContactMetadataDSL.parse(note: $0.note).isFavorite }
.map(\.identifier)
)
self.myCardIdentifier = fetched.first(where: {
ContactMetadataDSL.parse(note: $0.note).isMyCard
})?.identifier
} else {
self.favoriteContactIdentifiers = []
self.myCardIdentifier = nil
}
}
}
}
}
nonisolated private static var baseKeys: [CNKeyDescriptor] {
[
CNContactViewController.descriptorForRequiredKeys(),
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactNicknameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactThumbnailImageDataKey as CNKeyDescriptor
]
}
nonisolated private static func isUnauthorizedNoteError(_ error: NSError) -> Bool {
guard error.domain == CNErrorDomain,
error.code == 102 else {
return false
}
if let keyPaths = error.userInfo["CNKeyPaths"] as? [String] {
return keyPaths.contains("note")
}
return true
}
nonisolated private static func fetchContacts(using store: CNContactStore, keys: [CNKeyDescriptor]) throws -> [CNContact] {
var fetched: [CNContact] = []
let request = CNContactFetchRequest(keysToFetch: keys)
request.sortOrder = .userDefault
try store.enumerateContacts(with: request) { contact, _ in
fetched.append(contact)
}
return fetched
}
private func updateMetadata(for contact: CNContact, mutate: (inout ContactMetadata) -> Void) {
guard supportsMetadataDSL else {
lastError = String(localized: "contacts.metadata_unavailable")
return
}
do {
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactNoteKey as CNKeyDescriptor
]
let fetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: keys)
guard let mutable = fetched.mutableCopy() as? CNMutableContact else { return }
var metadata = ContactMetadataDSL.parse(note: mutable.note)
mutate(&metadata)
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
let request = CNSaveRequest()
request.update(mutable)
try store.execute(request)
refresh()
} catch let error as NSError {
if Self.isUnauthorizedNoteError(error) {
supportsMetadataDSL = false
lastError = String(localized: "contacts.metadata_unavailable")
} else {
lastError = error.localizedDescription
}
} catch {
lastError = error.localizedDescription
}
}
}