import Foundation /// A structure to hold identified numbers and their labels. struct IdentifiedEntry: Codable { let number: Int64 let label: String } /// Service to manage spam and identification numbers in a shared App Group container. final class SpamDirectoryService { static let shared = SpamDirectoryService() private let appGroupIdentifier = "group.com.frenocorp.kordant" private let blockedNumbersFile = "blocked_numbers.bin" private let identifiedEntriesFile = "identified_entries.json" private let fileManager = FileManager.default private var appGroupURL: URL? { fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) } private init() {} // MARK: - Blocked Numbers func addBlockedNumber(_ number: Int64) throws { var blocked = try loadBlockedNumbers() if !blocked.contains(number) { blocked.append(number) blocked.sort() try saveBlockedNumbers(blocked) } } func removeBlockedNumber(_ number: Int64) throws { var blocked = try loadBlockedNumbers() if let index = blocked.firstIndex(of: number) { blocked.remove(at: index) try saveBlockedNumbers(blocked) } } func loadBlockedNumbers() throws -> [Int64] { guard let url = appGroupURL?.appendingPathComponent(blockedNumbersFile) else { return [] } if !fileManager.fileExists(atPath: url.path) { return [] } let data = try Data(contentsOf: url) return data.withUnsafeBytes { Array($0.bindMemory(to: Int64.self)) } } private func saveBlockedNumbers(_ numbers: [Int64]) throws { guard let url = appGroupURL?.appendingPathComponent(blockedNumbersFile) else { return } let data = numbers.withUnsafeBytes { Data($0) } try data.write(to: url, options: .atomic) } // MARK: - Identified Numbers func addIdentifiedNumber(_ number: Int64, label: String) throws { var entries = try loadIdentifiedEntries() if let index = entries.firstIndex(where: { $0.number == number }) { entries[index] = IdentifiedEntry(number: number, label: label) } else { entries.append(IdentifiedEntry(number: number, label: label)) } // Sort by number to satisfy CallKit requirement entries.sort { $0.number < $1.number } try saveIdentifiedEntries(entries) } func removeIdentifiedNumber(_ number: Int64) throws { var entries = try loadIdentifiedEntries() entries.removeAll { $0.number == number } try saveIdentifiedEntries(entries) } func loadIdentifiedEntries() throws -> [IdentifiedEntry] { guard let url = appGroupURL?.appendingPathComponent(identifiedEntriesFile) else { return [] } if !fileManager.fileExists(atPath: url.path) { return [] } let data = try Data(contentsOf: url) return try JSONDecoder().decode([IdentifiedEntry].self, from: data) } private func saveIdentifiedEntries(_ entries: [IdentifiedEntry]) throws { guard let url = appGroupURL?.appendingPathComponent(identifiedEntriesFile) else { return } let data = try JSONEncoder().encode(entries) try data.write(to: url, options: .atomic) } // MARK: - Sync and Management /// Clears all data. Useful for testing or resetting. func clearAll() throws { guard let url = appGroupURL else { return } let blockedURL = url.appendingPathComponent(blockedNumbersFile) let identifiedURL = url.appendingPathComponent(identifiedEntriesFile) if fileManager.fileExists(atPath: blockedURL.path) { try fileManager.removeItem(at: blockedURL) } if fileManager.fileExists(atPath: identifiedURL.path) { try fileManager.removeItem(at: identifiedURL) } } }