- Add KordantSpamShieldExtension target to project.yml with proper app-extension type, bundle identifier, and deployment target - Create CallKit + App Group entitlements for SpamShield extension - Move SpamDirectoryService to Sources/Shared for cross-target access - Update app-review-checklist with 5 new technical items (total: 121) - Update rejection-risk-mitigation with extension build integration - Add SpamShield extension details to reviewer notes - Mark Task 24 (push deep links) and Task 28 as complete
118 lines
4.0 KiB
Swift
118 lines
4.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|