Migrate region storage from UserDefaults to disk#1103
Migrate region storage from UserDefaults to disk#1103mosliem wants to merge 4 commits intoOneBusAway:mainfrom
Conversation
- Add RegionsFileStorage (protocol + implementation) to persist downloaded regions to Application Support and custom regions to Documents as individual JSON files - Update RegionsService to use file-based storage; currentRegion now stores only the regionIdentifier in UserDefaults instead of the full Region object - Add one-time migration from legacy UserDefaults format to disk, clearing legacy keys after migration
aaronbrethorst
left a comment
There was a problem hiding this comment.
Hey Mohamed, I like the direction of this PR — moving region data out of UserDefaults into proper file storage is the right architectural call, and the protocol/mock design makes it cleanly testable. The TemporaryDirectoryFileManager subclass for test isolation is a nice approach. Before we can merge this, I will need you to make a couple changes:
Critical
-
Migration deletes legacy UserDefaults keys even when the save fails (
RegionsService.swift:297,314,322): InmigrateFromUserDefaultsIfNeeded,userDefaults.removeObject(forKey:)runs unconditionally — regardless of whethersaveDefaultRegionsthrew or whethertry? PropertyListDecoder().decode(...)returned nil. If the disk write fails (out of storage, sandbox error) or the decode fails (model schema changed between versions), the legacy data is permanently deleted from UserDefaults and nothing was written to disk. On the next launch, the app silently falls back to bundled regions and the user's previously-stored region list is gone forever.The fix: only call
removeObjectinside thedoblock aftersaveDefaultRegionssucceeds. For the decode failure case, either leave the legacy key intact (so migration retries on next launch) or at minimum log the raw data before deleting. The same pattern applies to all three migration blocks (default regions, custom regions, current region). -
currentRegiongetter triggers synchronous disk I/O on every access (RegionsService.swift:155-159,200,233-234): The getter callsfind(id:), which falls back tocustomRegions(line 200).customRegionsis a computed property that callsfileStorage.loadCustomRegions()— which doescontentsOfDirectory+Data(contentsOf:)for every file — on every single property access.currentRegionis read fromMapViewController,StopViewController,BookmarksViewController,SearchInteractor, and many other hot paths (includingupdateCurrentRegionFromLocation, which fires on every location update). For any user with a custom region selected, this means synchronous disk I/O on every location callback. Even for users with a standard region,find(id:)still falls through to the disk read when the identifier isn't found in the in-memoryregionsarray.The fix: cache custom regions in memory (invalidate on
add/delete), or cache the resolvedcurrentRegionobject and invalidate whenregionschanges or when the UserDefaults identifier changes.
Important
-
loadCustomRegionserror handling is asymmetric withloadDefaultRegions(RegionsFileStorage.swift:18vs25):loadDefaultRegions()throws errors to the caller.loadCustomRegions()silently swallows all errors (URL resolution failure, directory listing failure) and returns[]. The caller cannot distinguish "no custom regions exist" from "the Documents directory is inaccessible." SincesaveCustomRegionanddeleteCustomRegionboth throw,loadCustomRegionsshould also throw for total-failure conditions. Per-file decode errors can still be caught and skipped internally (thecompactMappattern is fine for partial failures). -
No test for the migration failure path (
RegionsServiceTests.swift): The three migration tests only cover the success path. There's no test verifying behavior whensaveDefaultRegionsthrows during migration — which is exactly the scenario from Critical issue #1. A test that configuresMockRegionsFileStorageto throw on save, populates a legacy UserDefaults key, and asserts the legacy key is NOT deleted would catch this data-loss bug and prevent regressions.
Fit and Finish
-
currentRegionsetter silently ignoresnil(RegionsService.swift:162):guard let newValue else { return }means you can never deselect a region. The getter can returnnil(when no identifier is stored, or whenfind(id:)returns nil because the region was deleted), but the setter refusesnil. This asymmetry was pre-existing, but it's worth a brief doc comment on the property explaining the contract. -
Throwing computed properties (
RegionsFileStorage.swift:49-71):var defaultRegionsFileURL: URL { get throws }is valid Swift but unusual — most Swift codebases express fallible URL construction as functions. Consider converting tofunc defaultRegionsFileURL() throws -> URLfor consistency with common Swift conventions.
Thanks again, and I look forward to merging this change.
- Add NSLock to protect customRegionsCache from data races between CoreLocation callbacks and async mutations (add/delete) - Cache custom regions in memory to eliminate repeated disk I/O on location callbacks - Log raw data in migration decode failures for diagnostics - Extract migration logic into focused helper methods for clarity
…ps, and clean up. - Shared mock storage with injectable errors for better coverage - Added migration failure test to ensure legacy data is preserved - Fixed incorrect test setups and seeding issues.
Summary
Closes #629
Migrates region data out of
UserDefaultsinto proper disk-basedstorage, with the following structure:
Application Support/Regions/default-regions.jsonDocuments/custom-regions/<id>.json(one file per region — synced via iCloud and accessible through Files.app)UserDefaults(identifier only, not the full object)Changes
OBAKitCoreRegionsFileStorage.swift—RegionsFileStorageProtocol+ concreteRegionsFileStorageimplementation. All directory URL construction usesfileManager.url(for:in:appropriateFor:create:)(throws, no force-unwraps).Corrupted custom region files are individually logged and skipped so other
regions still load.
RegionsService.swift:fileStorage: RegionsFileStorageProtocolparameter (defaults toRegionsFileStorage()— no breaking change to existing call sites)currentRegionnow stores only theregionIdentifier(Int) in UserDefaults;the full
Regionis looked up at read time viafind(id:), preventing staleobject data on disk
currentRegionsetter compares identifiers directly to avoid a disk read onevery location update
first launch; legacy keys are removed after migration so it runs only once
OBAKitTestsRegionsFileStorageTests.swiftRegionsServiceTests.swiftVideo
region.migration.mov