diff --git a/OBAKitCore/Location/Regions/RegionsService.swift b/OBAKitCore/Location/Regions/RegionsService.swift index c314d9070..ec89294bc 100644 --- a/OBAKitCore/Location/Regions/RegionsService.swift +++ b/OBAKitCore/Location/Regions/RegionsService.swift @@ -32,6 +32,7 @@ public class RegionsService: NSObject, LocationServiceDelegate { private let apiService: RegionsAPIService? private let locationService: LocationService private let userDefaults: UserDefaults + private let fileManager: RegionsServiceFileManagerProtocol private let bundledRegionsFilePath: String private let apiPath: String? @@ -40,13 +41,15 @@ public class RegionsService: NSObject, LocationServiceDelegate { /// - apiService: Retrieves new data from the region server and turns it into models. /// - locationService: A location service object. /// - userDefaults: The user defaults object. + /// - fileManager: The file manager object. /// - bundledRegionsFilePath: The path to the bundled regions file. It is probably named "regions.json" or something similar. /// - apiPath: The path to the remote regions.json file on the server. e.g. /path/to/regions.json /// - delegate: A delegate object for callbacks. - public init(apiService: RegionsAPIService?, locationService: LocationService, userDefaults: UserDefaults, bundledRegionsFilePath: String, apiPath: String?, delegate: RegionsServiceDelegate? = nil) { + public init(apiService: RegionsAPIService?, locationService: LocationService, userDefaults: UserDefaults, fileManager: RegionsServiceFileManagerProtocol, bundledRegionsFilePath: String, apiPath: String?, delegate: RegionsServiceDelegate? = nil) { self.apiService = apiService self.locationService = locationService self.userDefaults = userDefaults + self.fileManager = fileManager self.bundledRegionsFilePath = bundledRegionsFilePath self.apiPath = apiPath @@ -55,7 +58,7 @@ public class RegionsService: NSObject, LocationServiceDelegate { RegionsService.alwaysRefreshRegionsOnLaunchUserDefaultsKey: false ]) - if let regions = RegionsService.loadStoredRegions(from: userDefaults), regions.count > 0 { + if let regions = RegionsService.loadStoredRegions(from: fileManager), regions.count > 0 { self.regions = regions } else { @@ -204,17 +207,10 @@ public class RegionsService: NSObject, LocationServiceDelegate { // MARK: - Custom Regions /// Adds the provided custom region to the RegionsService. - /// If an existing custom region with the same `regionIdentifier` exists, the new region replaces the existing region. - /// - throws: Persistence storage errors. + /// - throws: File system errors. public func add(customRegion newRegion: Region) async throws { - var regions = customRegions - if let index = regions.firstIndex(where: { $0.regionIdentifier == newRegion.regionIdentifier }) { - regions.remove(at: index) - } - - regions.append(newRegion) - - try userDefaults.encodeUserDefaultsObjects(regions, key: RegionsService.storedCustomRegionsUserDefaultsKey) + let customRegionPath = RegionsService.getCustomRegionPath(customRegionIdentifier: newRegion.regionIdentifier) + try fileManager.save(newRegion, to: customRegionPath) } /// Deletes the custom region. If the region could not be found, this method exits normally. @@ -228,28 +224,27 @@ public class RegionsService: NSObject, LocationServiceDelegate { /// - parameter identifier: The region identifier used to find the custom region to delete. /// - throws: If a custom region with the provided `identifier` could not be found, or is not a custom region, this method will throw. public func delete(customRegionIdentifier identifier: RegionIdentifier) async throws { - var regions = customRegions - + guard self.currentRegion?.regionIdentifier != identifier else { throw UnstructuredError( "Cannot delete the current selected region", recoverySuggestion: "Choose a different region to be the currently selected region, before deleting this region.") } - - guard let index = regions.firstIndex(where: { $0.regionIdentifier == identifier }) else { - return - } - - regions.remove(at: index) - try userDefaults.encodeUserDefaultsObjects(regions, key: RegionsService.storedCustomRegionsUserDefaultsKey) + + let customRegionPath = RegionsService.getCustomRegionPath(customRegionIdentifier: identifier) + try fileManager.remove(at: customRegionPath) } + /// Retrieves an array of custom regions loaded from files in the custom regions directory. + /// - Returns: An array of `Region` objects representing custom regions. public var customRegions: [Region] { - let regions: [Region] - do { - regions = try userDefaults.decodeUserDefaultsObjects(type: [Region].self, key: RegionsService.storedCustomRegionsUserDefaultsKey) ?? [] - } catch { - regions = [] + var regions: [Region] = [] + if let fileURLs = try? fileManager.urls(at: RegionsService.customRegionsPath) { + for url in fileURLs { + if let region = try? fileManager.load(Region.self, from: url) { + regions.append(region) + } + } } return regions } @@ -262,16 +257,20 @@ public class RegionsService: NSObject, LocationServiceDelegate { public static let alwaysRefreshRegionsOnLaunchUserDefaultsKey = "OBAAlwaysRefreshRegionsOnLaunchUserDefaultsKey" static let automaticallySelectRegionUserDefaultsKey = "OBAAutomaticallySelectRegionUserDefaultsKey" - static let storedRegionsUserDefaultsKey = "OBAStoredRegionsUserDefaultsKey" - static let storedCustomRegionsUserDefaultsKey = "OBAStoredCustomRegionsUserDefaultsKey" static let currentRegionUserDefaultsKey = "OBACurrentRegionUserDefaultsKey" static let regionsUpdatedAtUserDefaultsKey = "OBARegionsUpdatedAtUserDefaultsKey" + static let defaultRegionsPath = URL.applicationSupportDirectory.appendingPathComponent("default-regions.json") + static let customRegionsPath = URL.documentsDirectory.appendingPathComponent("Regions/custom-regions") + private class func getCustomRegionPath(customRegionIdentifier identifier: RegionIdentifier) -> URL { + return customRegionsPath.appendingPathComponent("region-\(identifier).json") + } + // MARK: - Save Regions private func storeRegions() { do { - try userDefaults.encodeUserDefaultsObjects(regions, key: RegionsService.storedRegionsUserDefaultsKey) + try fileManager.save(regions, to: RegionsService.defaultRegionsPath) userDefaults.set(Date(), forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) } catch { @@ -281,11 +280,11 @@ public class RegionsService: NSObject, LocationServiceDelegate { // MARK: - Load Stored Regions - private class func loadStoredRegions(from userDefaults: UserDefaults) -> [Region]? { + private class func loadStoredRegions(from fileManager: RegionsServiceFileManagerProtocol) -> [Region]? { let regions: [Region] do { - regions = try userDefaults.decodeUserDefaultsObjects(type: [Region].self, key: RegionsService.storedRegionsUserDefaultsKey) ?? [] + regions = try fileManager.load([Region].self, from: RegionsService.defaultRegionsPath) } catch { return nil } diff --git a/OBAKitCore/Location/Regions/RegionsServiceFileManagerProtocol.swift b/OBAKitCore/Location/Regions/RegionsServiceFileManagerProtocol.swift new file mode 100644 index 000000000..b6d813d76 --- /dev/null +++ b/OBAKitCore/Location/Regions/RegionsServiceFileManagerProtocol.swift @@ -0,0 +1,74 @@ +// +// RegionsServiceFileManagerProtocol.swift +// OBAKit +// +// Copyright © Open Transit Software Foundation +// This source code is licensed under the Apache 2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +/// A protocol defining file management operations. +public protocol RegionsServiceFileManagerProtocol: AnyObject { + + /// Saves the Codable object to the specified directory with the given name. + /// + /// - Parameters: + /// - object: The Codable object to save. + /// - destination: The destination specifying where to save the object. + func save(_ object: T, to destination: URL) throws + + /// Loads a Codable object of the specified type from the file with the given URL. + /// + /// - Parameters: + /// - type: The type of the Codable object to load. + /// - fileURL: The URL of the file to load the object from. + /// - Returns: The decoded Codable object. + func load(_ type: T.Type, from fileURL: URL) throws -> T + + /// Removes the file at the specified destination. + /// + /// - Parameters: + /// - destination: The destination specifying which file to remove. + func remove(at destination: URL) throws + + /// Returns an array of URLs for the items at the specified URL. + /// + /// - Parameters: + /// - destination: The destination specifying where to load contents of. + /// - Returns: An array of URLs for the items in the directory. + func urls(at destination: URL) throws -> [URL] + +} + +extension FileManager: RegionsServiceFileManagerProtocol { + + /// Creates a directory at the specified URL if it doesn't already exist. + private func createDirectoryIfNeeded(at url: URL) throws { + guard !fileExists(atPath: url.path) else { return } + try createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + + public func urls(at destination: URL) throws -> [URL] { + return try contentsOfDirectory(at: destination, includingPropertiesForKeys: nil) + } + + public func load(_ type: T.Type, from fileURL: URL) throws -> T { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(T.self, from: data) + } + + public func save(_ object: T, to destination: URL) throws { + let directoryURL = destination.deletingLastPathComponent() + try createDirectoryIfNeeded(at: directoryURL) + let encoded = try JSONEncoder().encode(object) + try encoded.write(to: destination) + } + + public func remove(at destination: URL) throws { + guard fileExists(atPath: destination.path) else { return } + try removeItem(at: destination) + } + +} diff --git a/OBAKitCore/Orchestration/CoreApplication.swift b/OBAKitCore/Orchestration/CoreApplication.swift index 64fec1fff..3cf0f9a2c 100644 --- a/OBAKitCore/Orchestration/CoreApplication.swift +++ b/OBAKitCore/Orchestration/CoreApplication.swift @@ -28,6 +28,9 @@ open class CoreApplication: NSObject, /// Shared user defaults @objc public let userDefaults: UserDefaults + /// Default file manager. + private let fileManager: RegionsServiceFileManagerProtocol + /// The underlying implementation of our data stores. private let userDefaultsStore: UserDefaultsStore @@ -47,7 +50,7 @@ open class CoreApplication: NSObject, @objc public let locationService: LocationService /// Responsible for managing `Region`s and determining the correct `Region` for the user. - @objc public lazy var regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: self.config.bundledRegionsFilePath, apiPath: self.config.regionsAPIPath) + @objc public lazy var regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: self.config.bundledRegionsFilePath, apiPath: self.config.regionsAPIPath) /// Helper property that returns `regionsService.currentRegion`. @objc public var currentRegion: Region? { @@ -73,6 +76,7 @@ open class CoreApplication: NSObject, self.config = config userDefaults = config.userDefaults + fileManager = FileManager.default userDefaultsStore = UserDefaultsStore(userDefaults: userDefaults) locationService = config.locationService diff --git a/OBAKitTests/Helpers/Mocks/FileManagerMock.swift b/OBAKitTests/Helpers/Mocks/FileManagerMock.swift new file mode 100644 index 000000000..9f76470b8 --- /dev/null +++ b/OBAKitTests/Helpers/Mocks/FileManagerMock.swift @@ -0,0 +1,35 @@ +// +// FileManagerMock.swift +// OBAKitTests +// +// Copyright © Open Transit Software Foundation +// This source code is licensed under the Apache 2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import OBAKitCore + +class RegionsServiceFileManagerMock: RegionsServiceFileManagerProtocol { + + var savedObjects: [String: Data] = [:] + + func save(_ object: T, to destination: URL) throws where T : Decodable, T : Encodable { + let encodedData = try JSONEncoder().encode(object) + savedObjects[destination.path] = encodedData + } + + func load(_ type: T.Type, from fileURL: URL) throws -> T where T : Decodable { + let data = savedObjects[fileURL.path, default: Data()] + return try JSONDecoder().decode(T.self, from: data) + } + + func remove(at destination: URL) throws { + savedObjects.removeValue(forKey: destination.path) + } + + func urls(at destination: URL) throws -> [URL] { + return [] + } + +} diff --git a/OBAKitTests/Location/RegionsServiceTests.swift b/OBAKitTests/Location/RegionsServiceTests.swift index 8feabf144..d655869b1 100644 --- a/OBAKitTests/Location/RegionsServiceTests.swift +++ b/OBAKitTests/Location/RegionsServiceTests.swift @@ -43,6 +43,7 @@ class RegionsServiceTests: OBATestCase { var locationManagerMock: LocationManagerMock! var locationService: LocationService! var dataLoader: MockDataLoader! + var fileManager: RegionsServiceFileManagerProtocol! override func setUp() { super.setUp() @@ -51,6 +52,7 @@ class RegionsServiceTests: OBATestCase { locationManagerMock = LocationManagerMock() locationService = LocationService(userDefaults: UserDefaults(), locationManager: locationManagerMock) dataLoader = (regionsAPIService.dataLoader as! MockDataLoader) + fileManager = RegionsServiceFileManagerMock() } // MARK: - Upon creating the Regions Service @@ -59,7 +61,7 @@ class RegionsServiceTests: OBATestCase { func test_init_loadsBundledRegions() { stubRegions(dataLoader: dataLoader) - let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) + let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) XCTAssertEqual(service.regions.count, 13) } @@ -68,10 +70,9 @@ class RegionsServiceTests: OBATestCase { stubRegions(dataLoader: dataLoader) let customRegion = Fixtures.customMinneapolisRegion - let plistData = try PropertyListEncoder().encode([customRegion]) - userDefaults.set(plistData, forKey: RegionsService.storedRegionsUserDefaultsKey) - - let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) + try fileManager.save([customRegion], to: RegionsService.defaultRegionsPath) + + let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) let firstRegion = try XCTUnwrap(service.regions.first) XCTAssertEqual(firstRegion.name, "Custom Region", "Expected the first region to be the custom region") @@ -83,14 +84,13 @@ class RegionsServiceTests: OBATestCase { stubRegions(dataLoader: dataLoader) let customRegion = Fixtures.customMinneapolisRegion - let plistArrayData = try PropertyListEncoder().encode([customRegion]) - userDefaults.set(plistArrayData, forKey: RegionsService.storedRegionsUserDefaultsKey) + try fileManager.save(customRegion, to: RegionsService.defaultRegionsPath) userDefaults.set(false, forKey: RegionsService.automaticallySelectRegionUserDefaultsKey) let plistData = try PropertyListEncoder().encode(customRegion) userDefaults.set(plistData, forKey: RegionsService.currentRegionUserDefaultsKey) - let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) + let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) XCTAssertEqual(service.currentRegion, customRegion) } @@ -98,11 +98,11 @@ class RegionsServiceTests: OBATestCase { func test_init_loadsCurrentRegion_autoSelectEnabled() throws { stubRegions(dataLoader: dataLoader) - let plistData = try PropertyListEncoder().encode(Fixtures.customMinneapolisRegion) + let plistData = try JSONEncoder().encode(Fixtures.customMinneapolisRegion) userDefaults.set(plistData, forKey: RegionsService.currentRegionUserDefaultsKey) locationManagerMock.location = CLLocation(latitude: 47.632445, longitude: -122.312607) - let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) + let service = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsPath) let currentRegion = try XCTUnwrap(service.currentRegion) XCTAssertEqual(currentRegion.name, "Puget Sound") @@ -112,7 +112,7 @@ class RegionsServiceTests: OBATestCase { func test_init_updateRegionsList() async { stubRegionsJustPugetSound(dataLoader: dataLoader) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList() @@ -124,7 +124,7 @@ class RegionsServiceTests: OBATestCase { stubRegionsJustPugetSound(dataLoader: dataLoader) userDefaults.set(Date(), forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList() @@ -137,7 +137,7 @@ class RegionsServiceTests: OBATestCase { stubRegionsJustPugetSound(dataLoader: dataLoader) userDefaults.set(Date(), forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList(forceUpdate: true) XCTAssertFalse(testDelegate.regionUpdateCancelled.didCall, "Expected RegionsService to not inform delegates that a region update was cancelled") @@ -151,7 +151,7 @@ class RegionsServiceTests: OBATestCase { userDefaults.set(Date(), forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) userDefaults.set(true, forKey: RegionsService.alwaysRefreshRegionsOnLaunchUserDefaultsKey) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList() XCTAssertTrue(testDelegate.updatedRegionsList.didCall, "Expected RegionsService to inform delegates that the regionsList was updated") @@ -165,15 +165,15 @@ class RegionsServiceTests: OBATestCase { stubRegionsJustPugetSound(dataLoader: dataLoader) userDefaults.set(Date(), forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList(forceUpdate: true) XCTAssertTrue(testDelegate.updatedRegionsList.didCall) // Get regions from Persistence to ensure they were saved. let regions = try XCTUnwrap( - try userDefaults.decodeUserDefaultsObjects(type: [Region].self, key: RegionsService.storedRegionsUserDefaultsKey), - "Expected to get [Region] for \(RegionsService.storedRegionsUserDefaultsKey)" + try fileManager.load([Region].self, from: RegionsService.defaultRegionsPath), + "Expected to get [Region] from \(RegionsService.defaultRegionsPath.path)" ) XCTAssertEqual(regions.count, 1) @@ -181,12 +181,12 @@ class RegionsServiceTests: OBATestCase { } /// It loads the bundled regions when the data in the user defaults is corrupted. - func test_corruptedDefaults() { + func test_corruptedDefaults() throws { stubRegions(dataLoader: dataLoader) - self.userDefaults.set(["hello world!"], forKey: RegionsService.storedRegionsUserDefaultsKey) + try XCTUnwrap(fileManager.save(["hello world!"], to: RegionsService.defaultRegionsPath)) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) XCTAssertEqual(regionsService.regions.count, 13) } @@ -195,7 +195,7 @@ class RegionsServiceTests: OBATestCase { func test_regionUpdated_notifications() { stubRegions(dataLoader: dataLoader) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) let newRegion = Fixtures.customMinneapolisRegion @@ -212,7 +212,7 @@ class RegionsServiceTests: OBATestCase { stubRegionsJustPugetSound(dataLoader: dataLoader) userDefaults.set(Date.distantPast, forKey: RegionsService.regionsUpdatedAtUserDefaultsKey) - let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) + let regionsService = RegionsService(apiService: regionsAPIService, locationService: locationService, userDefaults: userDefaults, fileManager: fileManager, bundledRegionsFilePath: bundledRegionsPath, apiPath: regionsAPIPath, delegate: testDelegate) await regionsService.updateRegionsList() XCTAssertTrue(testDelegate.updatedRegionsList.didCall, "Expected RegionsService to inform delegates that the regionsList was updated")