Files
Kordant/iOS/Sources/Shared/SpamDirectoryService.swift
Michael Freno 6b729a1334 feat: integrate KordantSpamShieldExtension target and complete App Review compliance (Task 28)
- 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
2026-06-02 15:04:50 -04:00

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)
}
}
}