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 }