Deep dive into the managers, services, protocols, and data flow.
Files: Framework Layer/Managers/AppManager.swift, Protocols/AppManagerProtocol.swift
Singleton service locator (AppManager.shared). Creates, wires, and exposes all managers and services.
protocol AppManagerProtocol {
static var logger: Logger { get }
var persistanceManager: PersistanceManagerProtocol { get }
var defaultsManager: DefaultsManagerProtocol { get set }
var automaticFilterManager: AutomaticFilterManagerProtocol { get }
var messageEvaluationManager: MessageEvaluationManagerProtocol { get }
var networkSyncManager: NetworkSyncManagerProtocol { get }
var amazonS3Service: AmazonS3ServiceProtocol { get }
var reportMessageService: ReportMessageServiceProtocol { get }
var tipJarManager: TipJarManagerProtocol { get }
var filterTransferManager: FilterTransferManagerProtocol { get }
var flowManager: FlowManagerProtocol { get }
func onAppLaunch()
func onNewUserSession()
}PersistanceManager(in-memory if specified)DefaultsManagerMessageEvaluationManager(app: receives persistance manager; follows live context acrossreloadContainer())NetworkSyncManager(receives persistance manager)AmazonS3Service(receives network sync manager)ReportMessageService(receives network sync manager)AutomaticFilterManager(receives persistance manager + S3 service)TipJarManagerFilterTransferManager(receives persistance manager)FlowManager(receives defaults manager)- Logger wired to MessageEvaluationManager
In #if DEBUG + testing mode (-Testing launch argument): resets DefaultsManager and PersistanceManager.
onAppLaunch()— Initializes app age, detects new session (day boundary), triggers auto-filter update if online.onNewUserSession()— Increments session counter, updates session timestamp, fetches latest automatic filters.AppManager.previews— Static in-memory instance with debug data loaded, used by SwiftUI previews.
static let logger = Logger(subsystem: "com.grizz.apps.dev.Simply-Filter-SMS", category: "main")Files: Framework Layer/Managers/MessageEvaluationManager.swift, Protocols/MessageEvaluationManagerProtocol.swift
Core filtering engine. Shared between the main app and the Message Filter Extension.
protocol MessageEvaluationManagerProtocol {
var context: NSManagedObjectContext { get }
func evaluateMessage(body: String, sender: String) -> MessageEvaluationResult
func setLogger(_ logger: Logger)
}enum MessageEvaluationMatch {
case none, noMatch, storeUnavailable
case userFilter(String), smartFilter(String), automaticFilter(String)
}
struct MessageEvaluationResult {
var action: ILMessageFilterAction // .allow, .junk, .transaction, .promotion
var match: MessageEvaluationMatch
var reason: String? { match.caption } // logs / Test Filters caption
}-
Allow filters —
Filterrecords withtype == .allow. Returns.allowif matched. -
All Unknown — Active
AutomaticFiltersRulefor.allUnknown. Returns.junk(absolute gate after allows). -
Automatic filters (allow) — Active languages’ cached community lists:
allowSenders/allowBody. Returns.allow. -
Filter rules — Other active
AutomaticFiltersRules (.allUnknownskipped here):links— Body contains a URL (NSDataDetector)numbersOnly— Sender contains letters (i.e. not numbers-only)shortSender— Sender length ≤ configurable threshold (3–6)email— Sender looks like an emailemojis— Body contains emojicountryAllowlist— Sender’s calling code not in the selected allowlist
-
Deny filters —
Filterrecords withtype == .deny. Returns the filter’sdenyFolderType.action. -
Language deny —
Filterrecords withtype == .denyLanguage. Dominant body language viaNLLanguageRecognizer. -
Automatic filters (deny) — Cached community
denySender/denyBody. Returns.junk. -
No match —
.allowwithmatch: .noMatch.
Owned-store load failure (extension/tests) returns .allow with reason "storeUnavailable" before the pipeline runs.
For each user filter, matching depends on three settings:
- FilterTarget:
.all(sender+body combined),.sender, or.body - FilterMatching:
.contains,.exact(word boundaries), or.regex - FilterCase:
.caseInsensitiveor.caseSensitive
App: init(persistanceManager:) — ContextSource.persistance; context always reads PersistanceManager.context (survives reloadContainer()).
Extension / tests: init(inMemory:) — ContextSource.owned; owns an AppPersistentCloudKitContainer (isReadOnly: true unless in-memory). Loads the store synchronously in init (shouldAddStoreAsynchronously = false). On failure, logs and leaves an empty store; evaluate then allows without running filters. Successful load sets stalenessInterval = 0.
Files: Framework Layer/Managers/PersistanceManager.swift, Protocols/PersistanceManagerProtocol.swift
CoreData CRUD layer. Manages three entity types: Filter, AutomaticFiltersRule, AutomaticFiltersLanguage, plus cache records.
protocol PersistanceManagerProtocol {
var container: NSPersistentCloudKitContainer { get }
var fingerprint: String { get set }
// Filters
func fetchFilterRecords() -> [Filter]
func fetchFilterRecords(for filterType: FilterType) -> [Filter]
func addFilter(text:type:denyFolder:filterTarget:filterMatching:filterCase:)
func deleteFilters(withOffsets:in:)
func deleteFilters(_ filters: Set<Filter>)
func updateFilter(_ filter: Filter, denyFolder: DenyFolderType)
func updateFilter(_ filter: Filter, filterMatching: FilterMatching)
func updateFilter(_ filter: Filter, filterCase: FilterCase)
func updateFilter(_ filter: Filter, filterTarget: FilterTarget)
func updateFilter(_ filter: Filter, filterText: String)
func isDuplicateFilter(text:filterTarget:filterMatching:filterCase:) -> Bool
// Automatic filters
func fetchAutomaticFiltersLanguageRecords() -> [AutomaticFiltersLanguage]
func fetchAutomaticFiltersRuleRecords() -> [AutomaticFiltersRule]
func fetchAutomaticFiltersCacheRecords() -> [AutomaticFiltersCache]
func fetchAutomaticFiltersLanguageRecord(for language: NLLanguage) -> AutomaticFiltersLanguage?
func fetchAutomaticFiltersRuleRecord(for rule: RuleType) -> AutomaticFiltersRule?
func ensureAutomaticFiltersRuleRecord(for rule: RuleType) -> AutomaticFiltersRule
func saveCache(_ filterList: AutomaticFilterListsResponse)
func isCacheStale(_ filterList: AutomaticFilterListsResponse) -> Bool
// Lifecycle
func commitContext()
func reloadContainer()
#if DEBUG
func loadDebugData()
func reset()
#endif
}Uses AppPersistentCloudKitContainer (subclass of NSPersistentCloudKitContainer):
- Stores database in the App Group container for extension sharing
- Supports in-memory mode for tests/previews
- CloudKit sync is automatic via the container
Every CoreData model change requires a manual schema deployment to production.
The development CloudKit environment auto-evolves its schema when the app runs (new entities and attributes are picked up automatically). The production environment does not — it must be explicitly deployed via the CloudKit Console.
Steps after any CoreData model change:
- Run the debug build on a device or simulator and let it launch fully — the dev schema updates automatically.
- Go to CloudKit Console → select the app container → Schema → Environments.
- Review the diff between Development and Production carefully. Watch for stale record types from deleted/abandoned features — once deployed to production they cannot be removed.
- If the diff looks clean, click Deploy to Production.
Symptoms of a missing production schema field: data syncs correctly in debug builds but not in TestFlight/App Store builds. Fields silently dropped on upload; only previously-deployed fields come back after reinstall.
A computed string of filter UUIDs + automatic rule states (ruleId / isActive / selectedCountries) + language rows (lang / isActive). Does not include AutomaticFiltersCache. Used by NetworkSyncManager to detect whether a CloudKit import changed user-visible filter data.
saveCache()— EncodesAutomaticFilterListsResponseto base64, stores in CoreData with hash and timestamp. Deletes old cache records first.isCacheStale()— Compares encoded base64 strings of new vs cached filter lists.
loadDebugData()— Populates with sample filters (deny/allow/language) for previews and testing.reset()— Deletes all records from all entities.
Files: Framework Layer/Managers/AutomaticFilterManager.swift, Protocols/AutomaticFilterManagerProtocol.swift
Manages community-sourced filter lists fetched from AWS S3, plus smart rule states and language blocking states.
protocol AutomaticFilterManagerProtocol {
var isAutomaticFilteringOn: Bool { get }
var activeAutomaticFiltersTitle: String? { get }
var automaticFiltersCacheAge: Date? { get }
var rules: [RuleType] { get }
func languages(for mode: LanguageListView.Mode) -> [NLLanguage]
func languageAutomaticState(for language: NLLanguage) -> Bool
func setLanguageAutmaticState(for language: NLLanguage, value: Bool)
func automaticRuleState(for rule: RuleType) -> Bool
func setAutomaticRuleState(for rule: RuleType, value: Bool)
func selectedChoice(for rule: RuleType) -> Int
func setSelectedChoice(for rule: RuleType, choice: Int)
func updateAutomaticFiltersIfNeeded()
func forceUpdateAutomaticFilters() async
}isAutomaticFilteringOn— True if any language has automatic filtering enabled AND cache records exist.activeAutomaticFiltersTitle— Comma-separated localized names of active languages (e.g., "English, Hebrew").languages(for:)— Returns different lists per mode:.automaticBlocking— Languages available in the cached filter lists.blockLanguage— All supportedNLLanguagecases minus already-blocked ones
updateAutomaticFiltersIfNeeded()— Checks if cache is older thankUpdateAutomaticFiltersMinDays(3 days). If so, fetches from S3 in a backgroundTask.forceUpdateAutomaticFilters()— Async. Always fetches regardless of cache age. Used by pull-to-refresh.- After an S3 fetch, cache writes (
updateCacheIfNeeded) run on the MainActor so Core Data view-context work stays on the main thread. - Rule/language state changes persist immediately via
PersistanceManager.commitContext().
Files: Framework Layer/Managers/DefaultsManager.swift, Protocols/DefaultsManagerProtocol.swift
UserDefaults wrapper for app settings.
protocol DefaultsManagerProtocol {
var isAppFirstRun: Bool { get set }
var isExpandedAddFilter: Bool { get set }
var lastOfflineNotificationDismiss: Date? { get set }
var sessionAge: Date? { get set }
var sessionCounter: Int { get set }
var didPromptForReview: Bool { get set }
var lastSeenWhatsNewVersion: Int { get set }
var didDismissReportingExtensionNudge: Bool { get set }
var accentColorRGB: [String: Double] { get set }
var appAge: Date { get }
#if DEBUG
func reset()
#endif
}isAppFirstRun— Controls onboarding display. Set tofalseafter first dismiss.isExpandedAddFilter— Persists the expand/collapse state of AddFilterView's advanced options.sessionCounter/sessionAge— Track user sessions for review prompt logic.appAge— First launch date. Initialized once, never changes.didPromptForReview— Ensures App Store review prompt is shown only once.lastOfflineNotificationDismiss— Suppresses offline notification forkHideiClouldStatusMemory(60) minutes after dismiss.lastSeenWhatsNewVersion— Tracks the last What's New version the user has seen. Compared againstcurrentWhatsNewVersionto decide whether to show the What's New sheet.accentColorRGB—@StoredDefaultdictionary (kNoColorDict= system accent).Color(accentRGB:)/Color.accentRGBconvert. Debugreset()clears the key.
Files: Framework Layer/Managers/NetworkSyncManager.swift, Protocols/NetworkSyncManagerProtocol.swift
Monitors network connectivity and CloudKit sync status.
protocol NetworkSyncManagerProtocol: AnyObject {
var syncStatus: SyncStatus { get } // .unknown, .active, .failed
var networkStatus: NetworkStatus { get } // .unknown, .online, .offline
func onFirstStatusKnown(_ handler: @escaping () -> Void)
}extension NSNotification.Name {
static let networkStatusChange
static let cloudSyncOperationComplete
static let automaticFiltersUpdated
static let onClipboardSet
static let filtersStateChanged
static let persistentStoreReloaded
}Uses NWPathMonitor on a background queue. Posts .networkStatusChange on status changes.
Subscribes to NSPersistentCloudKitContainer.eventChangedNotification. Tracks three event types (setup, import, export). Maintains a "pre-sync fingerprint" (from PersistanceManager.fingerprint) to detect actual data changes during import.
On successful import: posts .cloudSyncOperationComplete (toast + refresh) when the fingerprint changed. Failed imports are logged separately.
AppHomeView.startMonitoring also compares store fingerprint to the last UI fingerprint once and refresh()es if the store is already ahead (import finished before observers were registered).
Reloads the CloudKit container on a genuine reconnection — NetworkStatus.isReconnection(from:), which is .offline → .online and nothing else. A failed setup is recorded as syncStatus = .failed for the UI and otherwise left alone.
.unknown is not a network state. It means the path monitor has not reported yet, so leaving it is a first report, never a reconnection. Treating it as one called reloadContainer() during CloudKit setup on a fresh install, tearing down the store and aborting the initial import — no filters for the whole first session, and duplicates after relaunch. Present from 2022-02-09 until dc22149; pinned by NetworkStatusTests.
maxSetupRetries is 0, and must stay there. scheduleSetupRetryIfNeeded was added (2026-08-13, 0ac0579) as an attempted fix for the bug above and was not the cause. Its "retry" is reloadContainer(), which destroys the NSCloudKitMirroringDelegate already running Core Data's own backoff recovery — one teardown is enough to lose the initial import on a fresh install, so 1 would not be safer than 2. Worse, setupRetryCount resets only on setup success, so once the attempts are spent sync is dead for the session with no path back on stable Wi-Fi. Core Data retries setup itself. The dead machinery should be deleted outright; the constant is at 0 as the smallest safe change before release.
PersistanceManager.reloadContainer() resets the view context (invalidating all managed objects), loads a new container, then posts .persistentStoreReloaded on the main queue. Screens that cache Core Data objects conform to ViewWithPersistentStoreReload and apply .modifier(persistentStoreReload) (PersistentStoreReload.swift).
Files: Framework Layer/Managers/TipJarManager.swift, Protocols/TipJarManagerProtocol.swift
Manages in-app purchase tip jar using StoreKit 2. Three consumable tip tiers (small, medium, large).
protocol TipJarManagerProtocol {
@MainActor var products: [Product] { get }
@MainActor var isLoadingProducts: Bool { get }
func purchase(_ product: Product) async -> TipPurchaseResult
}enum TipPurchaseResult {
case success(TipTier)
case userCancelled
case pending
case failure(Error)
}- Product loading — On init, launches a
Taskthat callsProduct.products(for:)withTipTier.allCasesproduct IDs. Products are sorted by price ascending. BothproductsandisLoadingProductsare@MainActor-isolated. - Transaction listener — Background
Tasklistens toTransaction.updatesfor server-side transaction completions. Finishes verified transactions automatically. - Unfinished transactions — On init, iterates
Transaction.unfinishedand finishes any verified pending transactions. - Purchase flow —
purchase(_:)handles all StoreKit result cases (success, userCancelled, pending, unknown) and verification. Returns a typedTipPurchaseResult. - StoreKit configuration — Local
TipJar.storekitfile (synced from App Store Connect) inResources/for simulator testing. Referenced in the scheme'sLaunchAction.
Defined in Constants.swift. CaseIterable enum with String raw values (product IDs). Computed properties: emoji, displayName, tierDescription, iconColor, confettiBirthRate, confettiLifetime, confettiVelocity.
Files: Framework Layer/Managers/FilterTransferManager.swift, Protocols/FilterTransferManagerProtocol.swift
Merge-only import/export of user filters. Export writes a versioned JSON payload to a .sfsfilters file in the temp directory — the user picks which filters first (same picker UI as import). Import never deletes existing filters; new rows get new UUIDs via addFilter. One in-flight picker at a time (pendingPreview / pendingKind / lastImportResult).
protocol FilterTransferManagerProtocol {
var pendingPreview: FilterTransferPreview { get }
var pendingKind: FilterTransferKind { get }
func exportPayload() throws -> Data
func writeExportFile(candidates: [FilterTransferCandidate]) throws -> URL
func queueExport() -> FilterTransferPreview
func clearPendingExport()
func deleteExportFile(at url: URL)
func isExportFile(_ url: URL) -> Bool
func readFile(at url: URL) throws -> Data
func previewImport(data: Data) throws -> FilterTransferPreview
func queueImport(data: Data) throws -> FilterTransferPreview
func clearPendingImport() -> FilterTransferResult?
func importFilters(_ candidates: [FilterTransferCandidate]) -> FilterTransferResult
}queueImportstarts an import session (pendingPreview,pendingKind = .importFilters). Home stillrecordLaunch(.filterImport)when there is nothing to add;presentNextFlowshows the native alert instead of the preview sheet.queueExportstarts an export session from local filters (pendingKind = .exportFilters). HomerequestSheet(.filterExport).importFilterswrites onto that session.clearPendingImportends it and returns the result (or nil) for the dismiss toast.writeExportFile(candidates:)writes only the selected rows and holds the temp URL. The export picker presents the share sheet on top of itself.clearPendingExport()runs when that picker dismisses and deletes the temp file.isExportFileis.sfsfiltersonly.deleteExportFileonly removes a.sfsfiltersfile inside the temp directory.- Custom UTI / document type:
kFilterExportTypeIdentifierinConstants.swift.
Files: Framework Layer/Managers/FlowManager.swift, Protocols/FlowManagerProtocol.swift
Launch-order queue for Home sheets. It is not a navigator — Home still owns sheetScreen.
protocol FlowManagerProtocol {
func recordLaunch(_ screen: Screen) -> Bool
func request(_ screen: Screen)
func enableWhatsNew()
func next() -> Screen?
func complete(_ screen: Screen)
func resetSession()
}- Occupancy:
next()setsactiveScreen. Furthernext()returns nil untilcomplete. - Order: first run (
.enableExtension) → launch (file / deep link) → automatic What's New (afterenableWhatsNew()) → userrequest. recordLaunchreturnsfalsefor.enableExtensionduring first run (already showing that sheet). Last successful launch wins.- Automatic What's New is skipped if the session started as first run or a launch claimed the session.
- Debug
AppManager.reset()clears the pending import andresetSession().
The inactivity notification alert does not go through FlowManager. It is a Home alert like
showNothingToImportAlert, raised from navigationScreen's didSet when navigation returns to
Home.
Files: Framework Layer/Managers/SchedulingManager.swift, Protocols/SchedulingManagerProtocol.swift
Keeps automatic filtering useful for people who never open the app. Two jobs: a background task that refreshes the filter lists when iOS is willing to run us, and a monthly banner that asks them to come back when it is not.
protocol SchedulingManagerProtocol: AnyObject {
var isFinalInactivityNotificationAsk: Bool { get }
func scheduleAutomaticFiltersProcessing()
func handleAutomaticFiltersProcessing(task: BGProcessingTask)
func refreshInactivityReminder()
func shouldShowInactivityNotificationAlert() async -> Bool
func requestInactivityNotificationPermission() async
func recordInactivityNotificationDecline()
#if DEBUG
func reset()
#endif // DEBUG
}- One
BGProcessingTask(kAutomaticFiltersProcessingTaskIdentifier),requiresNetworkConnectivity,earliestBeginDate= now +kUpdateAutomaticFiltersMinDays. Processing (not app refresh) targets idle/overnight windows, which suits an app that is rarely opened. - Registered in
AppDelegate.didFinishLaunching(iOS requires it before launch returns); submitted fromAppManager.onAppLaunch()and again at the top of every handled wake. - The handler calls the existing
updateAutomaticFiltersIfNeeded(), so the stale check, S3 client and App Group cache write are unchanged. The Message Filter Extension still never fetches. - Info.plist carries
processinginUIBackgroundModesand the identifier inBGTaskSchedulerPermittedIdentifiers.
- A single repeating notification (
kInactivityReminderNotificationIdentifier), first firing a month out atkInactivityReminderHour, then monthly. AUNCalendarNotificationTriggercannot express this: matching today's day of month would fire again the same evening, so an interval is used. - Only
AppDelegate.applicationDidBecomeActiverefreshes the clock. iOS also runsdidFinishLaunchingfor background task wakes, so nothing on the launch path may touch it — that callback fires only on a real foreground entry, which is the guarantee this relies on. - Cancelled when AI Filtering goes off, via
.filtersStateChangedobserved inside the manager. - No sound, no badge.
- Shown only when AI Filtering is on, this is not the first session, alerts are not already
allowed, fewer than
kInactivityNotificationMaxAsksdeclines are on record, and at leastkInactivityNotificationMinSessionsBetweenAskssessions have passed since the last decline. - Raised from
AppHomeView.ViewModel.navigationScreen'sdidSet, so it appears only on the way back to Home from a pushed screen — never on app open and never after a launch sheet, since dismissing a sheet does not touchnavigationScreen. It shares that hook withtryRequestReview(), which returns whether it prompted so the two never stack. shouldShowInactivityNotificationAlert()is read-only. Permission bookkeeping happens inrefreshInactivityReminder(), which is already asking iOS for the authorization status.- Remembering a grant (
inactivityNotificationWasGranted) is what lets a later revoke in Settings be noticed; when it is, the decline history is wiped and the conversation may start over. - A denial from the system prompt after Continue counts as a decline.
Files: Services Layer/Base/HTTPService.swift, Base/URLRequestProtocol.swift
Generic HTTP client with protocol-based request definitions.
protocol HTTPServiceProtocol {
func execute<T: Decodable>(type: T.Type, baseURL: URL, request: URLRequestProtocol) async throws -> T
}URLRequestProtocol defines: path, method (GET/POST/PUT/DELETE/PATCH), task (plain or with parameters), errorDomain.
HTTPServiceBase — Common base for services. Holds httpService: HTTPServiceProtocol and a weak networkSyncManager reference.
File: Services Layer/UserNotificationCenterService.swift
protocol UserNotificationCenterServiceProtocol: AnyObject {
func authorizationStatus() async -> UNAuthorizationStatus
func requestAlertAuthorization() async -> Bool
func schedule(_ request: UNNotificationRequest) async
func cancelPendingNotification(withIdentifier identifier: String)
}A pipe to UNUserNotificationCenter and nothing more — all policy lives in SchedulingManager.
UNAuthorizationStatus.allowsAlerts (extension in the same file) maps the platform enum, the same
way NWPath.Status.networkStatus does.
File: Services Layer/AmazonS3Service.swift
protocol AmazonS3ServiceProtocol: AnyObject {
func fetchAutomaticFilters() async -> AutomaticFilterListsResponse?
}- Fetches
GET /simply-filter-sms/1.0.0/automatic_filters.jsonfrom S3 - Guards against duplicate concurrent requests via
isFetchingflag - Returns
nilwhen offline or already fetching
File: Services Layer/ReportMessageService.swift
protocol ReportMessageServiceProtocol: AnyObject {
@discardableResult
func reportMessage(reportMessageRequestBody: ReportMessageRequestBody) async -> Bool
}- Posts to
POST /report(https://api.ben-dahan.com/report, public, no auth) - Body:
{ classification: { sender, bodies, type } } - Returns
trueon HTTP 200
AutomaticFilterListsResponse:
struct AutomaticFilterListsResponse: Codable {
let filterLists: [String: LanguageFilterListResponse] // language code -> filter lists
}
struct LanguageFilterListResponse: Codable {
let allowSenders: [String]
let allowBody: [String]
let denySender: [String]
let denyBody: [String]
}Supports base64 encoding/decoding for CoreData caching.
ReportMessageResponse:
struct ReportMessageResponse: Codable {
let statusCode: Int?
let message: String?
}File: Framework Layer/EmojiGenerator.swift
enum EmojiGenerator with a single static method randomEmoji() -> String. Picks from 11 Unicode emoji ranges, validates via isEmojiPresentation / isEmoji, retries up to 8 times, falls back to "smile". Used by the emoji rule toggle button in AppHomeView.
File: Framework Layer/SharedUITestsHelpers.swift
UIApplication.isInTestingMode— Checks for-Testingin process arguments (DEBUG only)TestIdentifierenum — All accessibility identifiers used by UI tests (e.g.,.addFilterButton,.appMenuButton,.testSenderInput)