ContactImageManager.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import Combine
  2. import Contacts
  3. import CoreData
  4. import Swinject
  5. protocol ContactImageManagerDelegate: AnyObject {
  6. func contactImageManagerDidUpdateState(_ state: ContactImageState)
  7. }
  8. protocol ContactImageManager {
  9. var delegate: ContactImageManagerDelegate? { get set }
  10. func requestAccess() async -> Bool
  11. func createContact(name: String) async -> String?
  12. func deleteContact(withIdentifier identifier: String) async -> Bool
  13. func updateContact(withIdentifier identifier: String, newName: String) async -> Bool
  14. @MainActor func updateContactImageState() async
  15. func setImageForContact(contactId: String) async
  16. func validateContactExists(withIdentifier identifier: String) async -> Bool
  17. }
  18. final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
  19. @Injected() private var glucoseStorage: GlucoseStorage!
  20. @Injected() private var contactImageStorage: ContactImageStorage!
  21. @Injected() private var settingsManager: SettingsManager!
  22. @Injected() private var fileStorage: FileStorage!
  23. private let contactStore = CNContactStore()
  24. // Make it read-only from outside the class
  25. private(set) var state = ContactImageState()
  26. private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  27. private let backgroundContext = CoreDataStack.shared.newTaskContext()
  28. private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
  29. private var subscriptions = Set<AnyCancellable>()
  30. private var units: GlucoseUnits = .mgdL
  31. private var deltaFormatter: NumberFormatter {
  32. let formatter = NumberFormatter()
  33. formatter.numberStyle = .decimal
  34. formatter.maximumFractionDigits = settingsManager.settings.units == .mmolL ? 1 : 0
  35. formatter.positivePrefix = "+"
  36. formatter.negativePrefix = "-"
  37. return formatter
  38. }
  39. weak var delegate: ContactImageManagerDelegate?
  40. init(resolver: Resolver) {
  41. super.init()
  42. injectServices(resolver)
  43. units = settingsManager.settings.units
  44. coreDataPublisher =
  45. changedObjectsOnManagedObjectContextDidSavePublisher()
  46. .receive(on: DispatchQueue.global(qos: .background))
  47. .share()
  48. .eraseToAnyPublisher()
  49. glucoseStorage.updatePublisher
  50. .receive(on: DispatchQueue.global(qos: .background))
  51. .sink { [weak self] _ in
  52. guard let self = self else { return }
  53. Task {
  54. await self.updateContactImageState()
  55. await self.updateContactImages()
  56. }
  57. }
  58. .store(in: &subscriptions)
  59. registerHandlers()
  60. }
  61. // MARK: - Core Data observation
  62. private func registerHandlers() {
  63. coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
  64. guard let self = self else { return }
  65. Task {
  66. await self.updateContactImageState()
  67. await self.updateContactImages()
  68. }
  69. }.store(in: &subscriptions)
  70. }
  71. // MARK: - Core Data Fetches
  72. private func fetchlastDetermination() async -> [NSManagedObjectID] {
  73. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  74. ofType: OrefDetermination.self,
  75. onContext: backgroundContext,
  76. predicate: NSPredicate(format: "deliverAt >= %@", Date.halfHourAgo as NSDate), // fetches enacted and suggested
  77. key: "deliverAt",
  78. ascending: false,
  79. fetchLimit: 1
  80. )
  81. return await backgroundContext.perform {
  82. guard let fetchedResults = results as? [OrefDetermination] else { return [] }
  83. return fetchedResults.map(\.objectID)
  84. }
  85. }
  86. private func fetchGlucose() async -> [NSManagedObjectID] {
  87. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  88. ofType: GlucoseStored.self,
  89. onContext: backgroundContext,
  90. predicate: NSPredicate.predicateFor20MinAgo,
  91. key: "date",
  92. ascending: false,
  93. fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
  94. )
  95. return await backgroundContext.perform {
  96. guard let glucoseResults = results as? [GlucoseStored] else {
  97. return []
  98. }
  99. return glucoseResults.map(\.objectID)
  100. }
  101. }
  102. private func getCurrentGlucoseTarget() async -> Decimal? {
  103. let now = Date()
  104. let calendar = Calendar.current
  105. let dateFormatter = DateFormatter()
  106. dateFormatter.dateFormat = "HH:mm"
  107. dateFormatter.timeZone = TimeZone.current
  108. let bgTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
  109. ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
  110. ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
  111. let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
  112. for (index, entry) in entries.enumerated() {
  113. guard let entryTime = dateFormatter.date(from: entry.start) else {
  114. print("Invalid entry start time: \(entry.start)")
  115. continue
  116. }
  117. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  118. let entryStartTime = calendar.date(
  119. bySettingHour: entryComponents.hour!,
  120. minute: entryComponents.minute!,
  121. second: entryComponents.second!,
  122. of: now
  123. )!
  124. let entryEndTime: Date
  125. if index < entries.count - 1,
  126. let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
  127. {
  128. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  129. entryEndTime = calendar.date(
  130. bySettingHour: nextEntryComponents.hour!,
  131. minute: nextEntryComponents.minute!,
  132. second: nextEntryComponents.second!,
  133. of: now
  134. )!
  135. } else {
  136. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  137. }
  138. if now >= entryStartTime, now < entryEndTime {
  139. return entry.value
  140. }
  141. }
  142. return nil
  143. }
  144. // MARK: - Configure ContactImageState in order to update ContactImageImage
  145. /// Updates the `ContactImageState` with the latest data from Core Data.
  146. /// This function fetches glucose values and determination entries, processes the data,
  147. /// and updates the `state` object, which represents the current contact trick state.
  148. /// - Important: This function must be called on the main actor to ensure thread safety. Otherwise, we would need to ensure thread safety by either using an actor or a perform closure
  149. @MainActor func updateContactImageState() async {
  150. // Get NSManagedObjectIDs on backgroundContext
  151. let glucoseValuesIds = await fetchGlucose()
  152. let determinationIds = await fetchlastDetermination()
  153. // Get NSManagedObjects on MainActor
  154. let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
  155. .getNSManagedObject(with: glucoseValuesIds, context: viewContext)
  156. let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
  157. .getNSManagedObject(with: determinationIds, context: viewContext)
  158. let lastDetermination = determinationObjects.last
  159. if let firstGlucoseValue = glucoseObjects.first {
  160. let value = settingsManager.settings.units == .mgdL
  161. ? Decimal(firstGlucoseValue.glucose)
  162. : Decimal(firstGlucoseValue.glucose).asMmolL
  163. state.glucose = Formatter.glucoseFormatter(for: units).string(from: value as NSNumber)
  164. state.trend = firstGlucoseValue.directionEnum?.symbol
  165. let delta = glucoseObjects.count >= 2
  166. ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects.dropFirst().first?.glucose ?? 0)
  167. : 0
  168. let deltaConverted = settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
  169. state.delta = deltaFormatter.string(from: deltaConverted as NSNumber)
  170. }
  171. state.lastLoopDate = lastDetermination?.timestamp
  172. let iobValue = lastDetermination?.iob as? Decimal ?? 0.0
  173. state.iob = iobValue
  174. state.iobText = Formatter.decimalFormatterWithOneFractionDigit.string(from: iobValue as NSNumber)
  175. // we need to do it complex and unelegant, otherwise unwrapping and parsing of cob results in 0
  176. if let cobValue = lastDetermination?.cob {
  177. state.cob = Decimal(cobValue)
  178. state.cobText = Formatter.integerFormatter.string(from: Int(cobValue) as NSNumber)
  179. } else {
  180. state.cob = 0
  181. state.cobText = "0"
  182. }
  183. if let eventualBG = settingsManager.settings.units == .mgdL ? lastDetermination?
  184. .eventualBG : lastDetermination?
  185. .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
  186. {
  187. let eventualBGAsString = Formatter.decimalFormatterWithOneFractionDigit.string(from: eventualBG)
  188. state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
  189. }
  190. // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
  191. let hardCodedLow = Decimal(55)
  192. let hardCodedHigh = Decimal(220)
  193. let isDynamicColorScheme = settingsManager.settings.glucoseColorScheme == .dynamicColor
  194. let highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.highGlucose
  195. let lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.lowGlucose
  196. state.highGlucoseColorValue = units == .mgdL ? highGlucoseColorValue : highGlucoseColorValue.asMmolL
  197. state.lowGlucoseColorValue = units == .mgdL ? lowGlucoseColorValue : lowGlucoseColorValue.asMmolL
  198. state
  199. .targetGlucose = await getCurrentGlucoseTarget() ??
  200. (settingsManager.settings.units == .mgdL ? Decimal(100) : 100.asMmolL)
  201. state.glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  202. // Notify delegate about state update on main thread
  203. await MainActor.run {
  204. delegate?.contactImageManagerDidUpdateState(state)
  205. }
  206. }
  207. // MARK: - Interactions with CNContactStore API
  208. /// Checks if the app has access to the user's contacts.
  209. func requestAccess() async -> Bool {
  210. await withCheckedContinuation { continuation in
  211. contactStore.requestAccess(for: .contacts) { granted, _ in
  212. continuation.resume(returning: granted)
  213. }
  214. }
  215. }
  216. /// Sets the image for a specific contact in Apple Contacts.
  217. /// This function fetches the associated `ContactImageEntry` for the provided contact ID, generates an image
  218. /// based on the current `ContactImageState`, and updates the contact in the user's Apple Contacts.
  219. /// - Parameter contactId: The unique identifier of the contact in Apple Contacts.
  220. /// - Important: This function should be called when a new contact is created and needs its initial image set.
  221. func setImageForContact(contactId: String) async {
  222. guard let contactEntry = await contactImageStorage.fetchContactImageEntries().first(where: { $0.contactId == contactId })
  223. else {
  224. debugPrint("\(DebuggingIdentifiers.failed) No matching ContactImageEntry found for contact ID: \(contactId)")
  225. return
  226. }
  227. // Create image based on current state
  228. let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
  229. do {
  230. let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
  231. let contacts = try contactStore.unifiedContacts(
  232. matching: predicate,
  233. keysToFetch: [
  234. CNContactIdentifierKey as CNKeyDescriptor,
  235. CNContactImageDataKey as CNKeyDescriptor
  236. ]
  237. )
  238. guard let contact = contacts.first else {
  239. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(contactId) not found.")
  240. return
  241. }
  242. let mutableContact = contact.mutableCopy() as! CNMutableContact
  243. mutableContact.imageData = newImage.pngData()
  244. let saveRequest = CNSaveRequest()
  245. saveRequest.update(mutableContact)
  246. try contactStore.execute(saveRequest)
  247. debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)")
  248. } catch {
  249. debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)")
  250. }
  251. }
  252. /// Updates the images of all contacts stored in Core Data.
  253. /// This function iterates through all stored `ContactImageEntry` objects, generates a new contact image
  254. /// based on the current `ContactImageState`, and updates the image in the user's Apple Contacts.
  255. /// - Important: This function should be called whenever the `ContactImageState` changes.
  256. func updateContactImages() async {
  257. // Iterate through all stored ContactImageEntry objects
  258. for contactEntry in await contactImageStorage.fetchContactImageEntries() {
  259. // Ensure the contact has a valid contact ID
  260. guard let contactId = contactEntry.contactId else { continue }
  261. // Generate a new image for the contact based on the updated state
  262. let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
  263. do {
  264. // Fetch the existing contact from CNContactStore using its identifier
  265. let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
  266. let contacts = try contactStore.unifiedContacts(
  267. matching: predicate,
  268. keysToFetch: [
  269. CNContactIdentifierKey as CNKeyDescriptor, // To identify the contact
  270. CNContactImageDataKey as CNKeyDescriptor // To fetch current image data
  271. ]
  272. )
  273. // Ensure the contact exists in the CNContactStore
  274. guard let contact = contacts.first else {
  275. debugPrint(
  276. "\(DebuggingIdentifiers.failed) Contact with ID \(contactId) and name \(contactEntry.name) not found."
  277. )
  278. continue
  279. }
  280. // Create a mutable copy of the contact to update its image
  281. let mutableContact = contact.mutableCopy() as! CNMutableContact
  282. mutableContact.imageData = newImage.pngData() // Set the new image data
  283. // Prepare a save request to update the contact
  284. let saveRequest = CNSaveRequest()
  285. saveRequest.update(mutableContact)
  286. // Execute the save request to persist the changes
  287. try contactStore.execute(saveRequest)
  288. debugPrint("\(DebuggingIdentifiers.succeeded) Updated contact image for \(contactId)")
  289. } catch {
  290. debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)")
  291. }
  292. }
  293. }
  294. /// Creates a new contact in the Apple contact list or updates an existing one with the same name.
  295. /// - Parameter name: The name of the contact.
  296. /// - Returns: The `identifier` of the created/updated contact, or `nil` if an error occurs.
  297. func createContact(name: String) async -> String? {
  298. do {
  299. // First check if a contact with this name already exists
  300. let predicate = CNContact.predicateForContacts(matchingName: name)
  301. let existingContacts = try contactStore.unifiedContacts(
  302. matching: predicate,
  303. keysToFetch: [
  304. CNContactIdentifierKey as CNKeyDescriptor,
  305. CNContactGivenNameKey as CNKeyDescriptor
  306. ]
  307. )
  308. // If contact exists, return its identifier
  309. if let existingContact = existingContacts.first {
  310. debugPrint("Found existing contact with name: \(name)")
  311. return existingContact.identifier
  312. }
  313. // If no existing contact, create a new one
  314. let contact = CNMutableContact()
  315. contact.givenName = name
  316. let saveRequest = CNSaveRequest()
  317. saveRequest.add(contact, toContainerWithIdentifier: nil)
  318. try contactStore.execute(saveRequest)
  319. // Re-fetch to get the identifier
  320. let newContacts = try contactStore.unifiedContacts(
  321. matching: predicate,
  322. keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
  323. )
  324. guard let createdContact = newContacts.first else {
  325. debugPrint("\(DebuggingIdentifiers.failed) Contact creation failed: No contact found after save.")
  326. return nil
  327. }
  328. return createdContact.identifier
  329. } catch {
  330. debugPrint("\(DebuggingIdentifiers.failed) Error creating/finding contact: \(error)")
  331. return nil
  332. }
  333. }
  334. /// Validates if a contact still exists in iOS Contacts.
  335. func validateContactExists(withIdentifier identifier: String) async -> Bool {
  336. let store = CNContactStore()
  337. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  338. let keys = [CNContactIdentifierKey] as [CNKeyDescriptor]
  339. do {
  340. let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
  341. return !contacts.isEmpty
  342. } catch {
  343. debugPrint("\(DebuggingIdentifiers.failed) Error validating contact: \(error)")
  344. return false
  345. }
  346. }
  347. /// Deletes a contact from the Apple contact list using its `identifier`.
  348. /// - Parameter identifier: The unique identifier of the contact.
  349. /// - Returns: `true` if the contact was successfully deleted, `false` otherwise.
  350. func deleteContact(withIdentifier identifier: String) async -> Bool {
  351. do {
  352. // Attempt to find the contact using its identifier.
  353. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  354. let contacts = try contactStore.unifiedContacts(
  355. matching: predicate,
  356. keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
  357. )
  358. guard let contact = contacts.first else {
  359. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
  360. return false
  361. }
  362. // Contact found -> Delete it.
  363. let mutableContact = contact.mutableCopy() as! CNMutableContact
  364. let deleteRequest = CNSaveRequest()
  365. deleteRequest.delete(mutableContact)
  366. try contactStore.execute(deleteRequest)
  367. debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)")
  368. return true
  369. } catch {
  370. debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)")
  371. return false
  372. }
  373. }
  374. /// Updates an existing contact in the Apple contact list.
  375. /// - Parameters:
  376. /// - identifier: The unique identifier of the contact.
  377. /// - newName: The new name to assign to the contact.
  378. /// - Returns: `true` if the contact was successfully updated, `false` otherwise.
  379. func updateContact(withIdentifier identifier: String, newName: String) async -> Bool {
  380. do {
  381. // Search for the contact using its `identifier`.
  382. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  383. let contacts = try contactStore.unifiedContacts(
  384. matching: predicate,
  385. keysToFetch: [
  386. CNContactIdentifierKey as CNKeyDescriptor,
  387. CNContactGivenNameKey as CNKeyDescriptor,
  388. CNContactFamilyNameKey as CNKeyDescriptor
  389. ]
  390. )
  391. guard let contact = contacts.first else {
  392. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
  393. return false
  394. }
  395. // Update the contact.
  396. let mutableContact = contact.mutableCopy() as! CNMutableContact
  397. mutableContact.givenName = newName
  398. let updateRequest = CNSaveRequest()
  399. updateRequest.update(mutableContact)
  400. try contactStore.execute(updateRequest)
  401. debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)")
  402. return true
  403. } catch {
  404. debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)")
  405. return false
  406. }
  407. }
  408. }