ContactTrickManager.swift 22 KB

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