ContactImageManager.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  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. state.highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.highGlucose
  195. state.lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.lowGlucose
  196. state
  197. .targetGlucose = await getCurrentGlucoseTarget() ??
  198. (settingsManager.settings.units == .mgdL ? Decimal(100) : 100.asMmolL)
  199. state.glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  200. // Notify delegate about state update on main thread
  201. await MainActor.run {
  202. delegate?.contactImageManagerDidUpdateState(state)
  203. }
  204. }
  205. // MARK: - Interactions with CNContactStore API
  206. /// Checks if the app has access to the user's contacts.
  207. func requestAccess() async -> Bool {
  208. await withCheckedContinuation { continuation in
  209. contactStore.requestAccess(for: .contacts) { granted, _ in
  210. continuation.resume(returning: granted)
  211. }
  212. }
  213. }
  214. /// Sets the image for a specific contact in Apple Contacts.
  215. /// This function fetches the associated `ContactImageEntry` for the provided contact ID, generates an image
  216. /// based on the current `ContactImageState`, and updates the contact in the user's Apple Contacts.
  217. /// - Parameter contactId: The unique identifier of the contact in Apple Contacts.
  218. /// - Important: This function should be called when a new contact is created and needs its initial image set.
  219. func setImageForContact(contactId: String) async {
  220. guard let contactEntry = await contactImageStorage.fetchContactImageEntries().first(where: { $0.contactId == contactId })
  221. else {
  222. debugPrint("\(DebuggingIdentifiers.failed) No matching ContactImageEntry found for contact ID: \(contactId)")
  223. return
  224. }
  225. // Create image based on current state
  226. let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
  227. do {
  228. let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
  229. let contacts = try contactStore.unifiedContacts(
  230. matching: predicate,
  231. keysToFetch: [
  232. CNContactIdentifierKey as CNKeyDescriptor,
  233. CNContactImageDataKey as CNKeyDescriptor
  234. ]
  235. )
  236. guard let contact = contacts.first else {
  237. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(contactId) not found.")
  238. return
  239. }
  240. let mutableContact = contact.mutableCopy() as! CNMutableContact
  241. mutableContact.imageData = newImage.pngData()
  242. let saveRequest = CNSaveRequest()
  243. saveRequest.update(mutableContact)
  244. try contactStore.execute(saveRequest)
  245. debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)")
  246. } catch {
  247. debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)")
  248. }
  249. }
  250. /// Updates the images of all contacts stored in Core Data.
  251. /// This function iterates through all stored `ContactImageEntry` objects, generates a new contact image
  252. /// based on the current `ContactImageState`, and updates the image in the user's Apple Contacts.
  253. /// - Important: This function should be called whenever the `ContactImageState` changes.
  254. func updateContactImages() async {
  255. // Iterate through all stored ContactImageEntry objects
  256. for contactEntry in await contactImageStorage.fetchContactImageEntries() {
  257. // Ensure the contact has a valid contact ID
  258. guard let contactId = contactEntry.contactId else { continue }
  259. // Generate a new image for the contact based on the updated state
  260. let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
  261. do {
  262. // Fetch the existing contact from CNContactStore using its identifier
  263. let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
  264. let contacts = try contactStore.unifiedContacts(
  265. matching: predicate,
  266. keysToFetch: [
  267. CNContactIdentifierKey as CNKeyDescriptor, // To identify the contact
  268. CNContactImageDataKey as CNKeyDescriptor // To fetch current image data
  269. ]
  270. )
  271. // Ensure the contact exists in the CNContactStore
  272. guard let contact = contacts.first else {
  273. debugPrint(
  274. "\(DebuggingIdentifiers.failed) Contact with ID \(contactId) and name \(contactEntry.name) not found."
  275. )
  276. continue
  277. }
  278. // Create a mutable copy of the contact to update its image
  279. let mutableContact = contact.mutableCopy() as! CNMutableContact
  280. mutableContact.imageData = newImage.pngData() // Set the new image data
  281. // Prepare a save request to update the contact
  282. let saveRequest = CNSaveRequest()
  283. saveRequest.update(mutableContact)
  284. // Execute the save request to persist the changes
  285. try contactStore.execute(saveRequest)
  286. debugPrint("\(DebuggingIdentifiers.succeeded) Updated contact image for \(contactId)")
  287. } catch {
  288. debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)")
  289. }
  290. }
  291. }
  292. /// Creates a new contact in the Apple contact list or updates an existing one with the same name.
  293. /// - Parameter name: The name of the contact.
  294. /// - Returns: The `identifier` of the created/updated contact, or `nil` if an error occurs.
  295. func createContact(name: String) async -> String? {
  296. do {
  297. // First check if a contact with this name already exists
  298. let predicate = CNContact.predicateForContacts(matchingName: name)
  299. let existingContacts = try contactStore.unifiedContacts(
  300. matching: predicate,
  301. keysToFetch: [
  302. CNContactIdentifierKey as CNKeyDescriptor,
  303. CNContactGivenNameKey as CNKeyDescriptor
  304. ]
  305. )
  306. // If contact exists, return its identifier
  307. if let existingContact = existingContacts.first {
  308. debugPrint("Found existing contact with name: \(name)")
  309. return existingContact.identifier
  310. }
  311. // If no existing contact, create a new one
  312. let contact = CNMutableContact()
  313. contact.givenName = name
  314. let saveRequest = CNSaveRequest()
  315. saveRequest.add(contact, toContainerWithIdentifier: nil)
  316. try contactStore.execute(saveRequest)
  317. // Re-fetch to get the identifier
  318. let newContacts = try contactStore.unifiedContacts(
  319. matching: predicate,
  320. keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
  321. )
  322. guard let createdContact = newContacts.first else {
  323. debugPrint("\(DebuggingIdentifiers.failed) Contact creation failed: No contact found after save.")
  324. return nil
  325. }
  326. return createdContact.identifier
  327. } catch {
  328. debugPrint("\(DebuggingIdentifiers.failed) Error creating/finding contact: \(error)")
  329. return nil
  330. }
  331. }
  332. /// Validates if a contact still exists in iOS Contacts.
  333. func validateContactExists(withIdentifier identifier: String) async -> Bool {
  334. let store = CNContactStore()
  335. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  336. let keys = [CNContactIdentifierKey] as [CNKeyDescriptor]
  337. do {
  338. let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
  339. return !contacts.isEmpty
  340. } catch {
  341. debugPrint("\(DebuggingIdentifiers.failed) Error validating contact: \(error)")
  342. return false
  343. }
  344. }
  345. /// Deletes a contact from the Apple contact list using its `identifier`.
  346. /// - Parameter identifier: The unique identifier of the contact.
  347. /// - Returns: `true` if the contact was successfully deleted, `false` otherwise.
  348. func deleteContact(withIdentifier identifier: String) async -> Bool {
  349. do {
  350. // Attempt to find the contact using its identifier.
  351. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  352. let contacts = try contactStore.unifiedContacts(
  353. matching: predicate,
  354. keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
  355. )
  356. guard let contact = contacts.first else {
  357. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
  358. return false
  359. }
  360. // Contact found -> Delete it.
  361. let mutableContact = contact.mutableCopy() as! CNMutableContact
  362. let deleteRequest = CNSaveRequest()
  363. deleteRequest.delete(mutableContact)
  364. try contactStore.execute(deleteRequest)
  365. debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)")
  366. return true
  367. } catch {
  368. debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)")
  369. return false
  370. }
  371. }
  372. /// Updates an existing contact in the Apple contact list.
  373. /// - Parameters:
  374. /// - identifier: The unique identifier of the contact.
  375. /// - newName: The new name to assign to the contact.
  376. /// - Returns: `true` if the contact was successfully updated, `false` otherwise.
  377. func updateContact(withIdentifier identifier: String, newName: String) async -> Bool {
  378. do {
  379. // Search for the contact using its `identifier`.
  380. let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
  381. let contacts = try contactStore.unifiedContacts(
  382. matching: predicate,
  383. keysToFetch: [
  384. CNContactIdentifierKey as CNKeyDescriptor,
  385. CNContactGivenNameKey as CNKeyDescriptor,
  386. CNContactFamilyNameKey as CNKeyDescriptor
  387. ]
  388. )
  389. guard let contact = contacts.first else {
  390. debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
  391. return false
  392. }
  393. // Update the contact.
  394. let mutableContact = contact.mutableCopy() as! CNMutableContact
  395. mutableContact.givenName = newName
  396. let updateRequest = CNSaveRequest()
  397. updateRequest.update(mutableContact)
  398. try contactStore.execute(updateRequest)
  399. debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)")
  400. return true
  401. } catch {
  402. debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)")
  403. return false
  404. }
  405. }
  406. }