diff --git a/BeeKit/Managers/CurrentUserManager.swift b/BeeKit/Managers/CurrentUserManager.swift index 8af0c4831..2f2b89317 100644 --- a/BeeKit/Managers/CurrentUserManager.swift +++ b/BeeKit/Managers/CurrentUserManager.swift @@ -6,6 +6,7 @@ // Copyright (c) 2015 APB. All rights reserved. // +import CoreData import Foundation import KeychainSwift import SwiftyJSON @@ -33,16 +34,19 @@ public class CurrentUserManager { private let keychain = KeychainSwift(keyPrefix: CurrentUserManager.keychainPrefix) private let requestManager: RequestManager - + private let container: BeeminderPersistentContainer + fileprivate static var allKeys: [String] { [accessTokenKey, usernameKey, deadbeatKey, defaultLeadtimeKey, defaultAlertstartKey, defaultDeadlineKey, beemTZKey] } let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier)! - init(requestManager: RequestManager) { + init(requestManager: RequestManager, container: BeeminderPersistentContainer) { self.requestManager = requestManager - migrateValues() + self.container = container + migrateValuesToGroupStore() + migrateValuesToCoreData() } /// Migrate settings values from the standard store to a group store @@ -51,7 +55,7 @@ public class CurrentUserManager { /// these values are not available within extensions. To address this now values are stored in a /// group-scoped settings object. Values written by old versions of the app may be in the previous store /// so we migrate any such values on initialization. - private func migrateValues() { + private func migrateValuesToGroupStore() { for key in CurrentUserManager.allKeys { let standardValue = UserDefaults.standard.object(forKey: key) let groupValue = userDefaults.object(forKey: key) @@ -74,7 +78,48 @@ public class CurrentUserManager { userDefaults.removeObject(forKey: CurrentUserManager.accessTokenKey) UserDefaults.standard.removeObject(forKey: CurrentUserManager.accessTokenKey) } - + + // If there is an existing session based on UserDefaults, create a new User object + private func migrateValuesToCoreData() { + // If there is already a session do nothing + if user() != nil { + return + } + + // If we are logged out, do nothing + if accessToken == nil || userDefaults.object(forKey: CurrentUserManager.usernameKey) == nil { + return + } + + // Create a new user + let _ = User(context: container.viewContext, + username: userDefaults.object(forKey: CurrentUserManager.usernameKey) as! String, + deadbeat: userDefaults.object(forKey: CurrentUserManager.deadbeatKey) != nil, + timezone: userDefaults.object(forKey: CurrentUserManager.beemTZKey) as? String ?? "Unknown", + defaultAlertStart: (userDefaults.object(forKey: CurrentUserManager.defaultAlertstartKey) ?? 0) as! Int32, + defaultDeadline: (userDefaults.object(forKey: CurrentUserManager.defaultDeadlineKey) ?? 0) as! Int32, + defaultLeadTime: (userDefaults.object(forKey: CurrentUserManager.defaultLeadtimeKey) ?? 0) as! Int32 + ) + try! container.viewContext.save() + } + + + private func user() -> User? { + // Fetch a user from the persistent store + let request = NSFetchRequest(entityName: "User") + // TODO: Handle (or at least log) an error here + let users = try? container.viewContext.fetch(request) + return users?.first + } + + private func deleteUser() throws { + // Delete any existing users. We expect at most one, but delete all to be safe. + while let user = self.user() { + container.viewContext.delete(user) + } + try container.viewContext.save() + } + /// Write a value to the UserDefaults store /// /// During migration to the appGroup shared store we still want to support users downgrading @@ -95,31 +140,35 @@ public class CurrentUserManager { } public var username :String? { - return userDefaults.object(forKey: CurrentUserManager.usernameKey) as! String? + return user()?.username } public var signingUp : Bool = false public func defaultLeadTime() -> NSNumber { - return (userDefaults.object(forKey: CurrentUserManager.defaultLeadtimeKey) ?? 0) as! NSNumber + return (user()?.defaultLeadTime ?? 0) as NSNumber } public func setDefaultLeadTime(_ leadtime : NSNumber) { - self.set(leadtime, forKey: CurrentUserManager.defaultLeadtimeKey) } - + user()?.defaultDeadline = leadtime as! Int32 + self.set(leadtime, forKey: CurrentUserManager.defaultLeadtimeKey) + } + public func defaultAlertstart() -> NSNumber { - return (userDefaults.object(forKey: CurrentUserManager.defaultAlertstartKey) ?? 0) as! NSNumber + return (user()?.defaultAlertStart ?? 0) as NSNumber } public func setDefaultAlertstart(_ alertstart : NSNumber) { + user()?.defaultAlertStart = alertstart as! Int32 self.set(alertstart, forKey: CurrentUserManager.defaultAlertstartKey) } public func defaultDeadline() -> NSNumber { - return (userDefaults.object(forKey: CurrentUserManager.defaultDeadlineKey) ?? 0) as! NSNumber + return (user()?.defaultDeadline ?? 0) as NSNumber } public func setDefaultDeadline(_ deadline : NSNumber) { + user()?.defaultDeadline = deadline as! Int32 self.set(deadline, forKey: CurrentUserManager.defaultDeadlineKey) } @@ -128,14 +177,15 @@ public class CurrentUserManager { } public func isDeadbeat() -> Bool { - return userDefaults.object(forKey: CurrentUserManager.deadbeatKey) != nil + return user()?.deadbeat ?? false } public func timezone() -> String { - return userDefaults.object(forKey: CurrentUserManager.beemTZKey) as? String ?? "Unknown" + return user()?.timezone ?? "Unknown" } public func setDeadbeat(_ deadbeat: Bool) { + user()?.deadbeat = deadbeat if deadbeat { self.set(true, forKey: CurrentUserManager.deadbeatKey) } else { @@ -149,14 +199,25 @@ public class CurrentUserManager { public func signInWithEmail(_ email: String, password: String) async { do { - let response = try await requestManager.post(url: "api/private/sign_in", parameters: ["user": ["login": email, "password": password], "beemios_secret": self.beemiosSecret] as Dictionary) - await self.handleSuccessfulSignin(JSON(response!)) + let response = try await requestManager.post(url: "api/private/sign_in", parameters: ["user": ["login": email, "password": password], "beemios_secret": self.beemiosSecret] as Dictionary) + try! await self.handleSuccessfulSignin(JSON(response!)) } catch { - await self.handleFailedSignin(error, errorMessage: error.localizedDescription) + try! await self.handleFailedSignin(error, errorMessage: error.localizedDescription) } } - func handleSuccessfulSignin(_ responseJSON: JSON) async { + func handleSuccessfulSignin(_ responseJSON: JSON) async throws { + try deleteUser() + + let _ = User(context: container.viewContext, + username: responseJSON[CurrentUserManager.usernameKey].string!, + deadbeat: responseJSON["deadbeat"].boolValue, + timezone: responseJSON[CurrentUserManager.beemTZKey].string!, + defaultAlertStart: responseJSON[CurrentUserManager.defaultAlertstartKey].int32!, + defaultDeadline: responseJSON[CurrentUserManager.defaultDeadlineKey].int32!, + defaultLeadTime: responseJSON[CurrentUserManager.defaultLeadtimeKey].int32!) + try container.viewContext.save() + if responseJSON["deadbeat"].boolValue { self.setDeadbeat(true) } @@ -174,25 +235,31 @@ public class CurrentUserManager { public func syncNotificationDefaults() async throws { let response = try await requestManager.get(url: "api/v1/users/\(username!).json", parameters: [:]) let responseJSON = JSON(response!) + + let user = user()! + user.defaultAlertStart = responseJSON["default_alertstart"].int32! + user.defaultDeadline = responseJSON["default_deadline"].int32! + user.defaultLeadTime = responseJSON["default_leadtime"].int32! + self.set(responseJSON["default_alertstart"].number!, forKey: "default_alertstart") self.set(responseJSON["default_deadline"].number!, forKey: "default_deadline") self.set(responseJSON["default_leadtime"].number!, forKey: "default_leadtime") - } - func handleFailedSignin(_ responseError: Error, errorMessage : String?) async { + func handleFailedSignin(_ responseError: Error, errorMessage : String?) async throws { await Task { @MainActor in NotificationCenter.default.post(name: Notification.Name(rawValue: CurrentUserManager.failedSignInNotificationName), object: self, userInfo: ["error" : responseError]) }.value - await self.signOut() + try await self.signOut() } - public func signOut() async { - + public func signOut() async throws { await Task { @MainActor in NotificationCenter.default.post(name: Notification.Name(rawValue: CurrentUserManager.willSignOutNotificationName), object: self) }.value + try deleteUser() + keychain.delete(CurrentUserManager.accessTokenKey) self.removeObject(forKey: CurrentUserManager.deadbeatKey) self.removeObject(forKey: CurrentUserManager.usernameKey) diff --git a/BeeKit/Managers/GoalManager.swift b/BeeKit/Managers/GoalManager.swift index 157bc1044..26891542b 100644 --- a/BeeKit/Managers/GoalManager.swift +++ b/BeeKit/Managers/GoalManager.swift @@ -60,7 +60,7 @@ public actor GoalManager { /// Fetch and return the latest set of goals from the server public func fetchGoals() async throws -> [Goal] { guard let username = currentUserManager.username else { - await currentUserManager.signOut() + try await currentUserManager.signOut() return [] } diff --git a/BeeKit/Managers/RequestManager.swift b/BeeKit/Managers/RequestManager.swift index b068627d5..c0791ae38 100644 --- a/BeeKit/Managers/RequestManager.swift +++ b/BeeKit/Managers/RequestManager.swift @@ -47,7 +47,7 @@ public class RequestManager { if case .responseValidationFailed(let reason) = error { if case .unacceptableStatusCode(let code) = reason { if code == 401 { - await ServiceLocator.currentUserManager.signOut() + try? await ServiceLocator.currentUserManager.signOut() } } } diff --git a/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents new file mode 100644 index 000000000..889d9d675 --- /dev/null +++ b/BeeKit/Model/BeeminderModel.xcdatamodeld/BeeminderModel.xcdatamodel/contents @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/BeeKit/Model/BeeminderPersistantContainer.swift b/BeeKit/Model/BeeminderPersistantContainer.swift new file mode 100644 index 000000000..9f9d5cf1c --- /dev/null +++ b/BeeKit/Model/BeeminderPersistantContainer.swift @@ -0,0 +1,12 @@ +import UIKit +import CoreData + +class BeeminderPersistentContainer: NSPersistentContainer { + + override open class func defaultDirectoryURL() -> URL { + var storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.beeminder.beeminder") + storeURL = storeURL?.appendingPathComponent("beeminder.sqlite") + return storeURL! + } + +} diff --git a/BeeKit/Model/User.swift b/BeeKit/Model/User.swift new file mode 100644 index 000000000..5a08b35c4 --- /dev/null +++ b/BeeKit/Model/User.swift @@ -0,0 +1,45 @@ +import Foundation +import CoreData + +public class User: NSManagedObject { + @NSManaged public var username: String + @NSManaged public var deadbeat: Bool + @NSManaged public var timezone: String + @NSManaged public var defaultAlertStart: Int32 + @NSManaged public var defaultDeadline: Int32 + @NSManaged public var defaultLeadTime: Int32 + + public init(context: NSManagedObjectContext, + username: String, + deadbeat: Bool, + timezone: String, + defaultAlertStart: Int32, + defaultDeadline: Int32, + defaultLeadTime: Int32 + ) { + let entity = NSEntityDescription.entity(forEntityName: "User", in: context)! + super.init(entity: entity, insertInto: context) + self.username = username + self.deadbeat = deadbeat + self.timezone = timezone + self.defaultAlertStart = defaultAlertStart + self.defaultDeadline = defaultDeadline + self.defaultLeadTime = defaultLeadTime + } + + @available(*, unavailable) + public init() { + fatalError() + } + + @available(*, unavailable) + public init(context: NSManagedObjectContext) { + fatalError() + } + + public override init(entity: NSEntityDescription, insertInto: NSManagedObjectContext?) { + super.init(entity: entity, insertInto: insertInto) + } + + +} diff --git a/BeeKit/ServiceLocator.swift b/BeeKit/ServiceLocator.swift index ac5f98e6d..569317d89 100644 --- a/BeeKit/ServiceLocator.swift +++ b/BeeKit/ServiceLocator.swift @@ -9,9 +9,19 @@ import Foundation public class ServiceLocator { + static let persistentContainer: BeeminderPersistentContainer = { + let container = BeeminderPersistentContainer(name: "BeeminderModel") + container.loadPersistentStores { description, error in + if let error = error { + fatalError("Unable to load persistent stores: \(error)") + } + } + return container + }() + public static let requestManager = RequestManager() public static let signedRequestManager = SignedRequestManager(requestManager: requestManager) - public static let currentUserManager = CurrentUserManager(requestManager: requestManager) + public static let currentUserManager = CurrentUserManager(requestManager: requestManager, container: persistentContainer) public static let goalManager = GoalManager(requestManager: requestManager, currentUserManager: currentUserManager) public static let healthStoreManager = HealthStoreManager() public static let versionManager = VersionManager(requestManager: requestManager) diff --git a/BeeSwift.xcodeproj/project.pbxproj b/BeeSwift.xcodeproj/project.pbxproj index 1685a3b3d..caa78090c 100644 --- a/BeeSwift.xcodeproj/project.pbxproj +++ b/BeeSwift.xcodeproj/project.pbxproj @@ -94,6 +94,9 @@ E458C8292AD12057000DCA5C /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E458C8282AD12057000DCA5C /* OrderedCollections */; }; E458C8352AD1266B000DCA5C /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E458C8342AD1266B000DCA5C /* SwiftyJSON */; }; E458C8372AD12671000DCA5C /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E458C8362AD12671000DCA5C /* KeychainSwift */; }; + E46071012B451FA400305DB4 /* BeeminderModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E41286EC2A62DF330093D598 /* BeeminderModel.xcdatamodeld */; }; + E46071022B451FAC00305DB4 /* BeeminderPersistantContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46070F72B3FEFD400305DB4 /* BeeminderPersistantContainer.swift */; }; + E46071032B451FB100305DB4 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46070EE2B36A4D900305DB4 /* User.swift */; }; E462BA3629AC44EA00E80EF0 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = E462BA3529AC44EA00E80EF0 /* Alamofire */; }; E462BA3929AC450000E80EF0 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = E462BA3829AC450000E80EF0 /* AlamofireImage */; }; E462BA3C29AC453D00E80EF0 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = E462BA3B29AC453D00E80EF0 /* AlamofireNetworkActivityIndicator */; }; @@ -307,6 +310,7 @@ A1F8F07A232C05410060B83E /* Goal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Goal.swift; sourceTree = ""; }; A1F9D1E9211B9B7600E2BC93 /* EditDatapointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditDatapointViewController.swift; sourceTree = ""; }; E4040D732A7B5F0E008E7D0E /* WorkoutMinutesHealthKitMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutMinutesHealthKitMetric.swift; sourceTree = ""; }; + E41286ED2A62DF330093D598 /* BeeminderModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = BeeminderModel.xcdatamodel; sourceTree = ""; }; E417572C2A6446FE0029CDDA /* CurrentUserManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentUserManagerTests.swift; sourceTree = ""; }; E42CB451291727B200A35AB9 /* HealthKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitError.swift; sourceTree = ""; }; E43833932AC1473E0098A38F /* InlineDatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineDatePicker.swift; sourceTree = ""; }; @@ -317,6 +321,8 @@ E44CE77E2993351100394E87 /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; }; E44CE782299338C500394E87 /* GoalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoalManager.swift; sourceTree = ""; }; E457BE5C28C192B50012F5D0 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + E46070EE2B36A4D900305DB4 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + E46070F72B3FEFD400305DB4 /* BeeminderPersistantContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeeminderPersistantContainer.swift; sourceTree = ""; }; E462BA2D29A31C3B00E80EF0 /* SynchronizedBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedBox.swift; sourceTree = ""; }; E46DC80E2AA58DF20059FDFE /* PullToRefreshHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToRefreshHint.swift; sourceTree = ""; }; E46FF15A2984C522009F8C7A /* DateUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateUtils.swift; sourceTree = ""; }; @@ -614,6 +620,16 @@ name = Frameworks; sourceTree = ""; }; + E46070F62B3FEFB300305DB4 /* Model */ = { + isa = PBXGroup; + children = ( + E41286EC2A62DF330093D598 /* BeeminderModel.xcdatamodeld */, + E46070F72B3FEFD400305DB4 /* BeeminderPersistantContainer.swift */, + E46070EE2B36A4D900305DB4 /* User.swift */, + ); + path = Model; + sourceTree = ""; + }; E46070FD2B43D98600305DB4 /* Components */ = { isa = PBXGroup; children = ( @@ -687,6 +703,7 @@ children = ( E4E6426E290E27CB004F3EA9 /* HeathKit */, A106AD8B1AF1F62800C434E8 /* Managers */, + E46070F62B3FEFB300305DB4 /* Model */, A149147C1BE7A4FC0060600A /* UI */, A149147D1BE7A5140060600A /* Util */, E57BE6E22655EBDA00BA540B /* BeeKit.h */, @@ -1188,14 +1205,17 @@ E458C81A2AD11CB5000DCA5C /* WorkoutMinutesHealthKitMetric.swift in Sources */, E458C8112AD11C8B000DCA5C /* MindfulSessionHealthKitMetric.swift in Sources */, E458C80A2AD11C1C000DCA5C /* Constants.swift in Sources */, + E46071032B451FB100305DB4 /* User.swift in Sources */, E5A80E6826598A370016D9A0 /* (null) in Sources */, E57BE7042655EE1F00BA540B /* Config.swift in Sources */, E458C80E2AD11C77000DCA5C /* HealthStoreManager.swift in Sources */, E458C8062AD11BCD000DCA5C /* GoalManager.swift in Sources */, E458C8272AD11F81000DCA5C /* BSLabel.swift in Sources */, + E46071022B451FAC00305DB4 /* BeeminderPersistantContainer.swift in Sources */, E458C8252AD11E01000DCA5C /* BSButton.swift in Sources */, E458C81D2AD11CEC000DCA5C /* UIDevice.swift in Sources */, E458C8172AD11CA7000DCA5C /* TotalSleepMinutes.swift in Sources */, + E46071012B451FA400305DB4 /* BeeminderModel.xcdatamodeld in Sources */, E458C8072AD11BDB000DCA5C /* Goal.swift in Sources */, E458C80D2AD11C64000DCA5C /* Crypto.swift in Sources */, E458C8012AD11BB3000DCA5C /* RequestManager.swift in Sources */, @@ -2168,6 +2188,19 @@ productName = OrderedCollections; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + E41286EC2A62DF330093D598 /* BeeminderModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + E41286ED2A62DF330093D598 /* BeeminderModel.xcdatamodel */, + ); + currentVersion = E41286ED2A62DF330093D598 /* BeeminderModel.xcdatamodel */; + path = BeeminderModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = A196CB0C1AE4142E00B90A3E /* Project object */; } diff --git a/BeeSwift/AppDelegate.swift b/BeeSwift/AppDelegate.swift index 8e7be8b55..3ac3acedf 100644 --- a/BeeSwift/AppDelegate.swift +++ b/BeeSwift/AppDelegate.swift @@ -20,7 +20,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { logger.notice("application:didFinishLaunchingWithOptions") diff --git a/BeeSwift/Settings/SettingsViewController.swift b/BeeSwift/Settings/SettingsViewController.swift index ecf71e68d..39b411f9f 100644 --- a/BeeSwift/Settings/SettingsViewController.swift +++ b/BeeSwift/Settings/SettingsViewController.swift @@ -78,7 +78,7 @@ class SettingsViewController: UIViewController { func signOutButtonPressed() { Task { @MainActor in - await ServiceLocator.currentUserManager.signOut() + try! await ServiceLocator.currentUserManager.signOut() self.navigationController?.popViewController(animated: true) } } diff --git a/BeeSwiftTests/CurrentUserManagerTests.swift b/BeeSwiftTests/CurrentUserManagerTests.swift index a2703cc69..5e99b1b3d 100644 --- a/BeeSwiftTests/CurrentUserManagerTests.swift +++ b/BeeSwiftTests/CurrentUserManagerTests.swift @@ -10,7 +10,7 @@ final class CurrentUserManagerTests: XCTestCase { } func testCanSetAndRetrieveAccessToken() throws { - let currentUserManager = CurrentUserManager(requestManager: ServiceLocator.requestManager) + let currentUserManager = CurrentUserManager(requestManager: ServiceLocator.requestManager, container: ServiceLocator.persistentContainer) currentUserManager.setAccessToken("test_access_token") XCTAssertEqual(currentUserManager.accessToken, "test_access_token") } @@ -19,7 +19,7 @@ final class CurrentUserManagerTests: XCTestCase { let userDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier)! userDefaults.set("migrated_access_token", forKey: CurrentUserManager.accessTokenKey) - let currentUserManager = CurrentUserManager(requestManager: ServiceLocator.requestManager) + let currentUserManager = CurrentUserManager(requestManager: ServiceLocator.requestManager, container: ServiceLocator.persistentContainer) XCTAssertEqual(currentUserManager.accessToken, "migrated_access_token") // The value should also have been removed from UserDefaults