crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

NicknameDirectory.swift (3002B)


      1 import Foundation
      2 
      3 /// App Group-shared directory of the user's private friend nicknames, keyed
      4 /// by friend authorID and persisted as a JSON file in the group container.
      5 ///
      6 /// Notification text that names a friend is composed *sender-side* (the
      7 /// sender's device writes "Alice solved …" with its own chosen name), so the
      8 /// nickname can only be substituted on the receiving device — and when the
      9 /// app is suspended, the only code that runs is the Notification Service
     10 /// Extension, which cannot reach Core Data. The app therefore mirrors every
     11 /// nickname into this file via `FriendEntity.rebuildNicknameDirectory`, and
     12 /// the NSE reads it to rewrite the alert body before display. The name to
     13 /// replace arrives live on the push (`PushPayload.senderName`), so the only
     14 /// thing the directory needs to persist is the nickname itself.
     15 enum NicknameDirectory {
     16     /// The directory holds only the stable `authorID → nickname` mapping. The
     17     /// substring to replace (the friend's own name) is *not* stored here — it
     18     /// rides live on each push in `PushPayload.senderName`, so the rewrite
     19     /// always matches the name the sender actually used and can never go stale
     20     /// against a friend who has since renamed.
     21     struct Entry: Codable, Equatable, Sendable {
     22         /// The local user's private nickname for the friend.
     23         let nickname: String
     24     }
     25 
     26     /// Test-only override for the backing file. Mirrors
     27     /// `NotificationState.testingDefaults`: a `TaskLocal` so per-test
     28     /// temporary URLs flow through actor hops and stay isolated when suites
     29     /// run in parallel; production never sets it.
     30     @TaskLocal static var testingFileURL: URL?
     31 
     32     private static var fileURL: URL? {
     33         if let testingFileURL { return testingFileURL }
     34         return FileManager.default
     35             .containerURL(forSecurityApplicationGroupIdentifier: NotificationState.appGroup)?
     36             .appendingPathComponent("nickname-directory.json")
     37     }
     38 
     39     static func load() -> [String: Entry] {
     40         guard let url = fileURL,
     41               let data = try? Data(contentsOf: url),
     42               let directory = try? JSONDecoder().decode([String: Entry].self, from: data)
     43         else { return [:] }
     44         return directory
     45     }
     46 
     47     /// Replaces the file wholesale — the directory is small (one entry per
     48     /// renamed friend) and always rebuilt from Core Data ground truth, so
     49     /// there is no merge to do. An empty directory removes the file.
     50     static func save(_ directory: [String: Entry]) {
     51         guard let url = fileURL else { return }
     52         guard !directory.isEmpty else {
     53             try? FileManager.default.removeItem(at: url)
     54             return
     55         }
     56         guard let data = try? JSONEncoder().encode(directory) else { return }
     57         try? data.write(to: url, options: .atomic)
     58     }
     59 
     60     static func entry(for authorID: String) -> Entry? {
     61         guard !authorID.isEmpty else { return nil }
     62         return load()[authorID]
     63     }
     64 }