Skip to content

Commit

Permalink
Introduce core data
Browse files Browse the repository at this point in the history
Add user attributes

null safe explicit implementation

Update User object when user logs in and out

Minimal working version

Read values from CoreData

Store the persistent container in a shared location

Fix tests
  • Loading branch information
theospears committed Jan 3, 2024
1 parent cacab44 commit 21aeb53
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 29 deletions.
111 changes: 89 additions & 22 deletions BeeKit/Managers/CurrentUserManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Copyright (c) 2015 APB. All rights reserved.
//

import CoreData
import Foundation
import KeychainSwift
import SwiftyJSON
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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<User>(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
Expand All @@ -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)
}

Expand All @@ -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 {
Expand All @@ -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<String, Any>)
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<String, Any>)
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)
}
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion BeeKit/Managers/GoalManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}

Expand Down
2 changes: 1 addition & 1 deletion BeeKit/Managers/RequestManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23C71" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="User" representedClassName=".User" syncable="YES">
<attribute name="deadbeat" attributeType="Boolean" usesScalarValueType="NO"/>
<attribute name="defaultAlertStart" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO"/>
<attribute name="defaultDeadline" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO"/>
<attribute name="defaultLeadTime" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO"/>
<attribute name="timezone" attributeType="String"/>
<attribute name="username" attributeType="String"/>
</entity>
</model>
12 changes: 12 additions & 0 deletions BeeKit/Model/BeeminderPersistantContainer.swift
Original file line number Diff line number Diff line change
@@ -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!
}

}
45 changes: 45 additions & 0 deletions BeeKit/Model/User.swift
Original file line number Diff line number Diff line change
@@ -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)
}


}
12 changes: 11 additions & 1 deletion BeeKit/ServiceLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 21aeb53

Please sign in to comment.