diff --git a/AtMe.xcodeproj/project.pbxproj b/AtMe.xcodeproj/project.pbxproj index f8d9b8b..3e2ca35 100644 --- a/AtMe.xcodeproj/project.pbxproj +++ b/AtMe.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 5D1C70411E58C64A00FEDEC3 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5D1C703F1E58C64A00FEDEC3 /* Main.storyboard */; }; 5D1C70431E58C64A00FEDEC3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D1C70421E58C64A00FEDEC3 /* Assets.xcassets */; }; 5D1C70461E58C64A00FEDEC3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5D1C70441E58C64A00FEDEC3 /* LaunchScreen.storyboard */; }; + 5D28129C2132401200BD616E /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D28129B2132401200BD616E /* AuthManager.swift */; }; 5D2B62601EC909A300B8363E /* PromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2B625F1EC909A300B8363E /* PromptViewController.swift */; }; 5D2B62621EC909CC00B8363E /* PromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2B62611EC909CC00B8363E /* PromptView.swift */; }; 5D323B721F107C3E00853EDE /* EmptyChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D323B711F107C3E00853EDE /* EmptyChatListView.swift */; }; @@ -106,6 +107,7 @@ 5D22766420C9B658002E6958 /* AuthControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthControllerTests.swift; sourceTree = ""; }; 5D22766F20C9B6C8002E6958 /* AtMeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtMeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D22767320C9B6C8002E6958 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5D28129B2132401200BD616E /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; 5D2B625F1EC909A300B8363E /* PromptViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromptViewController.swift; sourceTree = ""; }; 5D2B62611EC909CC00B8363E /* PromptView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromptView.swift; sourceTree = ""; }; 5D323B711F107C3E00853EDE /* EmptyChatListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyChatListView.swift; sourceTree = ""; }; @@ -212,6 +214,7 @@ isa = PBXGroup; children = ( 5D43855F1EEC4D8F0005D7CD /* AtMe.entitlements */, + 5D28129A21323F6900BD616E /* Protocols */, 5D5EDF831EC3D01900F7FC44 /* Views */, 5D1C704F1E58C69C00FEDEC3 /* Cells */, 5D1C704E1E58C68A00FEDEC3 /* Controllers */, @@ -316,6 +319,14 @@ path = AtMeTests; sourceTree = ""; }; + 5D28129A21323F6900BD616E /* Protocols */ = { + isa = PBXGroup; + children = ( + 5D28129B2132401200BD616E /* AuthManager.swift */, + ); + name = Protocols; + sourceTree = ""; + }; 5D5EDF831EC3D01900F7FC44 /* Views */ = { isa = PBXGroup; children = ( @@ -643,6 +654,7 @@ 5DF36ED91EB3D35400346868 /* Message.swift in Sources */, 5D07CAB21E752C0100316D75 /* UserProfile.swift in Sources */, 5D9617381F316C6100222C79 /* ConvoAuxViewController.swift in Sources */, + 5D28129C2132401200BD616E /* AuthManager.swift in Sources */, 5D00D1061F33A72E00130C59 /* BlockedUserCell.swift in Sources */, 5D2B62601EC909A300B8363E /* PromptViewController.swift in Sources */, 5D6394F11F314F800095C1C5 /* LegalViewController.swift in Sources */, diff --git a/AtMe/AuthController.swift b/AtMe/AuthController.swift index 652ad4e..0d8026b 100644 --- a/AtMe/AuthController.swift +++ b/AtMe/AuthController.swift @@ -12,420 +12,424 @@ import FirebaseCore // Protocol to inform delegates of auth events protocol AuthenticationDelegate { - func userDidSignOut() + func userDidSignOut() } -class AuthController { - - lazy var databaseManager = DatabaseController() - - // MARK: - Properties - var authenticationDelegate: AuthenticationDelegate? - - // Firebase References - var userInformationRef: DatabaseReference = Database.database().reference().child("userInformation") - var registeredUsernamesRef: DatabaseReference = Database.database().reference().child("registeredUsernames") - var reportedUsersRecordRef: DatabaseReference = Database.database().reference().child("reportedUsersRecord") - - - init() { - - print("+ Initializing an AuthController") - print("userInformation, registeredUsernames and reportedUsers have set keepSynced=true") - - // Must keep these Firebase locations in sync to prevent stale offline data - userInformationRef.keepSynced(true) - registeredUsernamesRef.keepSynced(true) - reportedUsersRecordRef.keepSynced(true) - } - - - // MARK: - Account Management - /** - Asynchronously attempts to create an @Me account - - parameters: - - displayPicture: Firebase storage url for the users display picture (if set) - - email: Email address - - username: Username - - firstName: First name - - lastName: Last name - - password: Password - - completion: Callback that returns an Error object back to caller at completion - - error: An Error object returned from the Auth Controller - - uid: The UID assigned to the user upon successful account creation - */ - public func createAccount(email: String, firstName: String, lastName: String, - password: String, completion: @escaping ((Error?, String?) -> ()) ) { - - // If the username already exists, avoid creating user - // Look this up asynchronously in Firebase, call completion callback when finished regardless of findings - // Note: The must be done inside the observe block to properly update synchronously - - Auth.auth().createUser(withEmail: email, password: password, completion: { (user, error) in - - // Present backend errors to user when @Me does not catch them - if let error = error { - completion(error, user?.user.uid) - return - } - - // Add entry to usernames index and user info record - self.userInformationRef.child((user?.user.uid)!).setValue( - ["email" : email, - "firstName" : firstName, - "lastName" : lastName, - "notificationID": NotificationsController.currentDeviceNotificationID() ?? nil] - ) - - completion(error, user?.user.uid) - }) +class AuthController: AuthManager { + + lazy var databaseManager = DatabaseController() + + // MARK: - Properties + var authenticationDelegate: AuthenticationDelegate? + + // Firebase References + var userInformationRef: DatabaseReference = Database.database().reference().child("userInformation") + var registeredUsernamesRef: DatabaseReference = Database.database().reference().child("registeredUsernames") + var reportedUsersRecordRef: DatabaseReference = Database.database().reference().child("reportedUsersRecord") + + + init() { + + print("+ Initializing an AuthController") + print("userInformation, registeredUsernames and reportedUsers have set keepSynced=true") + + // Must keep these Firebase locations in sync to prevent stale offline data + userInformationRef.keepSynced(true) + registeredUsernamesRef.keepSynced(true) + reportedUsersRecordRef.keepSynced(true) + } + + + // MARK: - Account Management + /** + Asynchronously attempts to create an @Me account + - parameters: + - displayPicture: Firebase storage url for the users display picture (if set) + - email: Email address + - username: Username + - firstName: First name + - lastName: Last name + - password: Password + - completion: Callback that returns an Error object back to caller at completion + - error: An Error object returned from the Auth Controller + - uid: The UID assigned to the user upon successful account creation + */ + public func createAccount(email: String, firstName: String, lastName: String, + password: String, completion: @escaping ((Error?, String?) -> ()) ) { + + // If the username already exists, avoid creating user + // Look this up asynchronously in Firebase, call completion callback when finished regardless of findings + // Note: The must be done inside the observe block to properly update synchronously + + Auth.auth().createUser(withEmail: email, password: password, completion: { (user, error) in + + // Present backend errors to user when @Me does not catch them + if let error = error { + completion(error, user?.user.uid) + return + } + + // Add entry to usernames index and user info record + self.userInformationRef.child((user?.user.uid)!).setValue( + ["email" : email, + "firstName" : firstName, + "lastName" : lastName, + "notificationID": NotificationsController.currentDeviceNotificationID() ?? nil] + ) + + completion(error, user?.user.uid) + }) + } + + + /** + Asynchronously attempts to sign in to an @Me account + - parameters: + - email: Email address + - password: Password + - completion: Callback that returns an Error object back to caller at completion + - error: An Error object returned from the Auth Controller + - configured: A boolean representing if the current user object could be configured (required) + */ + public func signIn(email: String, password: String, completion: @escaping ((Error?, Bool) -> ()) ) { + + // Let the auth object sign in the user with given credentials + Auth.auth().signIn(withEmail: email, password: password) { (user, error) in + + // Call completion block with resulting error (hopefully nil when successful) + if let error = error { completion(error, false); return } + guard let user = user else { return } + + // Call database function to retrieve information about current user, and set the static current user object + // The completion callback returns a bool indicating success, so return that value in this completion callback too! + self.establishCurrentUser(user: user.user, completion: { configured in + completion(error, configured) + }) } - - - /** - Asynchronously attempts to sign in to an @Me account - - parameters: - - email: Email address - - password: Password - - completion: Callback that returns an Error object back to caller at completion - - error: An Error object returned from the Auth Controller - - configured: A boolean representing if the current user object could be configured (required) - */ - public func signIn(email: String, password: String, completion: @escaping ((Error?, Bool) -> ()) ) { - - // Let the auth object sign in the user with given credentials - Auth.auth().signIn(withEmail: email, password: password) { (user, error) in - - // Call completion block with resulting error (hopefully nil when successful) - if let error = error { completion(error, false); return } - guard let user = user else { return } - - // Call database function to retrieve information about current user, and set the static current user object - // The completion callback returns a bool indicating success, so return that value in this completion callback too! - self.establishCurrentUser(user: user.user, completion: { configured in - completion(error, configured) - }) - } + } + + + /** Take the appropriate steps to sign the user out of the application. */ + public func signOut() throws { + do { + try Auth.auth().signOut() + } catch let error { + throw error } - - - /** Take the appropriate steps to sign the user out of the application. */ - public func signOut() { + authenticationDelegate?.userDidSignOut() + databaseManager.clearCachedImages() + databaseManager.unsubscribeUserFromNotifications(uid: UserState.currentUser.uid) + UserState.resetCurrentUser() + } + + + // MARK: - Blocking users + // TODO: Refactor blocking logic into DatabaseController + /** + Add a given user to the current user's blocked usernames list. + - parameters: + - uid: The uid of the user whom the current user is blocking. + - username: The username of the user whom the current user is blocking. + */ + public func blockUser(uid: String, username: String) { + userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames/\(username)").setValue(uid) + } + + + /** + Remove a given user from the current user's blocked usernames list. + - parameters: + - uid: The uid of the user whom the current user is unblocking. + - username: The username of the user whom the current user is unblocking. + */ + public func unblockUser(uid: String, username: String) { + userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames/\(username)").removeValue() + } + + + /** + Find all users whom the current user has blocked. + - parameters: + - completion: A callback function *invoked once for every UserProfile found*. + - profile: The UserProfile object returned for a single given user. + */ + public func findCurrentUserBlockedUsers(completion: @escaping (UserProfile) -> Void) { + + userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { snapshot in + + // Attempt to unwrap list as a dictionary of (username, uid) pairs as stored + if let blockedUsers = snapshot.value as? [String : String] { - authenticationDelegate?.userDidSignOut() - databaseManager.clearCachedImages() - databaseManager.unsubscribeUserFromNotifications(uid: UserState.currentUser.uid) - UserState.resetCurrentUser() - } - - - - // MARK: - Blocking users - /** - Add a given user to the current user's blocked usernames list. - - parameters: - - uid: The uid of the user whom the current user is blocking. - - username: The username of the user whom the current user is blocking. - */ - public func blockUser(uid: String, username: String) { - userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames/\(username)").setValue(uid) - } - - - /** - Remove a given user from the current user's blocked usernames list. - - parameters: - - uid: The uid of the user whom the current user is unblocking. - - username: The username of the user whom the current user is unblocking. - */ - public func unblockUser(uid: String, username: String) { - userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames/\(username)").removeValue() - } - - - /** - Find all users whom the current user has blocked. - - parameters: - - completion: A callback function *invoked once for every UserProfile found*. - - profile: The UserProfile object returned for a single given user. - */ - public func findCurrentUserBlockedUsers(completion: @escaping (UserProfile) -> Void) { - - userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { snapshot in - - // Attempt to unwrap list as a dictionary of (username, uid) pairs as stored - if let blockedUsers = snapshot.value as? [String : String] { - - // Ask AuthController to find UserProfile objects for each found user - self.findDetailsForUsers(results: blockedUsers, completion: { userDetails in - completion(userDetails) - }) - } + // Ask AuthController to find UserProfile objects for each found user + self.findDetailsForUsers(results: blockedUsers, completion: { userDetails in + completion(userDetails) }) - } - - - /** - Determine if current user has blocked a given user, or vice versa. - - parameters: - - uid: The uid of the other user whom we are checking for blocked status. - - username: The username of the other user whom we are checking for blocked status. - - completion: A completion callback called when a conclusion has been reached. - - blocked: A variable passed through callback, which will be true if either user has blocked the other. - */ - public func userOrCurrentUserHasBlocked(uid: String, username: String, completion: @escaping (Bool) -> ()) { - - userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { snapshot in - self.userInformationRef.child(uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { otherSnap in - print("current: <\(snapshot)> other: <\(otherSnap)>") - - if let blockedList = snapshot.value as? [String : String] { - if blockedList[username] == uid { completion(true); return } - } - - if let blockedList = otherSnap.value as? [String : String] { - if blockedList[UserState.currentUser.username] == UserState.currentUser.uid { completion(true); return } - } - - completion(false) - }) - }) - } - - - - public func reportUser(uid: String, username: String, violation: String, convoID: String) { + } + }) + } + + + /** + Determine if current user has blocked a given user, or vice versa. + - parameters: + - uid: The uid of the other user whom we are checking for blocked status. + - username: The username of the other user whom we are checking for blocked status. + - completion: A completion callback called when a conclusion has been reached. + - blocked: A variable passed through callback, which will be true if either user has blocked the other. + */ + public func userOrCurrentUserHasBlocked(uid: String, username: String, completion: @escaping (Bool) -> ()) { + + userInformationRef.child(UserState.currentUser.uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { snapshot in + self.userInformationRef.child(uid).child("blockedUsernames").observeSingleEvent(of: DataEventType.value, with: { otherSnap in + print("current: <\(snapshot)> other: <\(otherSnap)>") - let record = ["uidReported": uid, "usernameReported": username, "violation": violation, - "relevantConvoID": convoID, "reportedBy": UserState.currentUser.username, - "timestamp": "\(Date().timeIntervalSince1970)"] - reportedUsersRecordRef.childByAutoId().setValue(record) - } - - - - // MARK: - User lookup - /** - Finds details for specified users, then returns a UserProfile for each found via a completion callback. - - parameters: - - results: A dictionary containing (username: uid) pairs for users - - completion: A completion callback invoked each time details are found for a user - - profile: The UserProfile object representing and holding the details found for a specific user - */ - public func findDetailsForUsers(results: [String : String], completion: @escaping (UserProfile) -> Void) { - - // For each result found, observe the user's full name and pass back as a UserProfile object - // Using this UserProfile, the table view can be updated with info by the caller! + if let blockedList = snapshot.value as? [String : String] { + if blockedList[username] == uid { completion(true); return } + } - for (username, uid) in results { - - userInformationRef.child(uid).observeSingleEvent(of: DataEventType.value, with: { snapshot in - - // Read first and last name, pass back to caller using callback when done - let first = snapshot.childSnapshot(forPath: "firstName").value as? String ?? "" - let last = snapshot.childSnapshot(forPath: "lastName").value as? String ?? "" - - let user = UserProfile(name: first + " " + last, uid: uid, username: username) - completion(user) - }) + if let blockedList = otherSnap.value as? [String : String] { + if blockedList[UserState.currentUser.username] == UserState.currentUser.uid { completion(true); return } } - } - - - /** - Performs a search using given string, and attempts to find a predefined number of users whom the user is most likely searching for. Please note that the search omits the current user. - - parameters: - - term: The term to search for and match usernames with - - completion: A completion callback that fires when it has found all the results it can - - results: An dictionary of (username, uid) pairs found in the search. Please note this may be empty if no results found! - */ - public func searchForUsers(term: String, completion: @escaping ([String : String]) -> ()) { - var handle: UInt = 0 - handle = registeredUsernamesRef.queryOrderedByKey().queryStarting(atValue: term).queryEnding(atValue: "\(term)\u{f8ff}") - .queryLimited(toFirst: Constants.Limits.resultsCount).observe(DataEventType.value, with: { snapshot in - - // Parse results as dictionary of (username, uid) pairs - if var results = snapshot.value as? [String : String] { - - // Never allow option to start conversation with yourself!! - results.removeValue(forKey: UserState.currentUser.username) - - // If and when found, pass results back to caller - completion(results) - } - - self.registeredUsernamesRef.removeObserver(withHandle: handle) - }) - } - - - /** - Asynchronously determines if a given username has been taken in the current database - - parameters: - - username: Username to search for - - completion: Callback that fires when function has finished - - found: True if username was found in database, false otherwise - */ - public func usernameExists(username: String, completion: @escaping (Bool) -> ()) { + completion(false) + }) + }) + } + + + + public func reportUser(uid: String, username: String, violation: String, convoID: String) { + + let record = ["uidReported": uid, "usernameReported": username, "violation": violation, + "relevantConvoID": convoID, "reportedBy": UserState.currentUser.username, + "timestamp": "\(Date().timeIntervalSince1970)"] + reportedUsersRecordRef.childByAutoId().setValue(record) + } + + + + // MARK: - User lookup + /** + Finds details for specified users, then returns a UserProfile for each found via a completion callback. + - parameters: + - results: A dictionary containing (username: uid) pairs for users + - completion: A completion callback invoked each time details are found for a user + - profile: The UserProfile object representing and holding the details found for a specific user + */ + public func findDetailsForUsers(results: [String : String], completion: @escaping (UserProfile) -> Void) { + + // For each result found, observe the user's full name and pass back as a UserProfile object + // Using this UserProfile, the table view can be updated with info by the caller! + + for (username, uid) in results { + + userInformationRef.child(uid).observeSingleEvent(of: DataEventType.value, with: { snapshot in - registeredUsernamesRef.observeSingleEvent(of: DataEventType.value, with: { snapshot in - - if (snapshot.hasChild(username)) { completion(true) } - else { completion(false) } - }) - } - - - /** Asynchronously determine name of user with a given uid. - - parameters: - - uid: The uid of the user being searched for - - completion: Completion handler called when search has finished - - name: The name of user found, but nil if not found - */ - public func findNameFor(uid: String, completion: @escaping (String?) -> Void) { + // Read first and last name, pass back to caller using callback when done + let first = snapshot.childSnapshot(forPath: "firstName").value as? String ?? "" + let last = snapshot.childSnapshot(forPath: "lastName").value as? String ?? "" - userInformationRef.observeSingleEvent(of: DataEventType.value, with: { snapshot in - - if let first = snapshot.childSnapshot(forPath: "\(uid)/firstName").value as? String, - let last = snapshot.childSnapshot(forPath: "\(uid)/lastName").value as? String { - completion(first + " " + last) - - } else { completion(nil) } - }) + let user = UserProfile(name: first + " " + last, uid: uid, username: username) + completion(user) + }) } - - - // MARK: - Current user maintenance - /** - Retrieve details for current user from the database. User must be authorized already. - - parameters: - - user: The current user, which should be authorized at this point - - completion:Callback that fires when function has finished - - configured: A boolean representing if the current user object could be configured (required) - */ - public func establishCurrentUser(user: User, completion: @escaping (Bool) -> ()) { + } + + + /** + Performs a search using given string, and attempts to find a predefined number of users whom the user is most likely searching for. Please note that the search omits the current user. + - parameters: + - term: The term to search for and match usernames with + - completion: A completion callback that fires when it has found all the results it can + - results: An dictionary of (username, uid) pairs found in the search. Please note this may be empty if no results found! + */ + public func searchForUsers(term: String, completion: @escaping ([String : String]) -> ()) { + + var handle: UInt = 0 + handle = registeredUsernamesRef.queryOrderedByKey().queryStarting(atValue: term).queryEnding(atValue: "\(term)\u{f8ff}") + .queryLimited(toFirst: Constants.Limits.resultsCount).observe(DataEventType.value, with: { snapshot in - // TODO: Change to take snapshot of only this user's info, use child(uid) - // Look up information about the User, set the UserState.currentUser object properties - self.userInformationRef.observeSingleEvent(of: DataEventType.value, with: { (snapshot) in - - // Important: Must be able to set ALL PROPERTIES of current user, else do not authorize! - guard let email = user.email, - let username = snapshot.childSnapshot(forPath: "\(user.uid)/username").value as? String, - let first = snapshot.childSnapshot(forPath: "\(user.uid)/firstName").value as? String, - let last = snapshot.childSnapshot(forPath: "\(user.uid)/lastName").value as? String - else { completion(false); return } - - // Obtain current notification ID, update it - // Notification ID must always be optional, because users may not allow for it, and also because - // notification id is removed from database at sign out (and will thus be empty at sign in) - - if let notificationID = NotificationsController.currentDeviceNotificationID() { - self.userInformationRef.child("\(user.uid)/notificationID").setValue(notificationID) - UserState.currentUser.notificationID = notificationID - } - - // Set all properties of currentUser now that they have been unwrapped if needed - UserState.currentUser.displayPicture = "\(user.uid)/\(user.uid).JPG" - UserState.currentUser.email = email - UserState.currentUser.name = first + " " + last - UserState.currentUser.uid = user.uid - UserState.currentUser.username = username - - completion(true) - }) - } - - - /** - Writes current user's username into their information record and usernames registry in the database. This should - never change after set, so only call when creating account. - - parameters: - - username: Username chosen by the current user - - completion: Callback that is called upon successful completion - */ - public func setUsername(username: String, completion: (() -> ())) { + // Parse results as dictionary of (username, uid) pairs + if var results = snapshot.value as? [String : String] { + + // Never allow option to start conversation with yourself!! + results.removeValue(forKey: UserState.currentUser.username) + + // If and when found, pass results back to caller + completion(results) + } - guard let uid = Auth.auth().currentUser?.uid else { return } + self.registeredUsernamesRef.removeObserver(withHandle: handle) + }) + } + + + /** + Asynchronously determines if a given username has been taken in the current database + - parameters: + - username: Username to search for + - completion: Callback that fires when function has finished + - found: True if username was found in database, false otherwise + */ + public func usernameExists(username: String, completion: @escaping (Bool) -> ()) { + + registeredUsernamesRef.observeSingleEvent(of: DataEventType.value, with: { snapshot in + + if (snapshot.hasChild(username)) { completion(true) } + else { completion(false) } + }) + } + + + /** Asynchronously determine name of user with a given uid. + - parameters: + - uid: The uid of the user being searched for + - completion: Completion handler called when search has finished + - name: The name of user found, but nil if not found + */ + public func findNameFor(uid: String, completion: @escaping (String?) -> Void) { + + userInformationRef.observeSingleEvent(of: DataEventType.value, with: { snapshot in + + if let first = snapshot.childSnapshot(forPath: "\(uid)/firstName").value as? String, + let last = snapshot.childSnapshot(forPath: "\(uid)/lastName").value as? String { + completion(first + " " + last) - // Set current user, update username field in userInformation and registeredUsernames - UserState.currentUser.username = username - userInformationRef.child("\(uid)/username").setValue(username) - registeredUsernamesRef.child(username).setValue(uid) - completion() - } - - - /** - Writes the database storage path of an uploaded display picture to the current user's information record - - parameters: - - path: The path where the display picture has been successfully uploaded to - */ - public func setDisplayPicture(path: String) { + } else { completion(nil) } + }) + } + + + // MARK: - Current user maintenance + /** + Retrieve details for current user from the database. User must be authorized already. + - parameters: + - user: The current user, which should be authorized at this point + - completion:Callback that fires when function has finished + - configured: A boolean representing if the current user object could be configured (required) + */ + public func establishCurrentUser(user: User, completion: @escaping (Bool) -> ()) { + + // TODO: Change to take snapshot of only this user's info, use child(uid) + // Look up information about the User, set the UserState.currentUser object properties + self.userInformationRef.observeSingleEvent(of: DataEventType.value, with: { (snapshot) in + + // Important: Must be able to set ALL PROPERTIES of current user, else do not authorize! + guard let email = user.email, + let username = snapshot.childSnapshot(forPath: "\(user.uid)/username").value as? String, + let first = snapshot.childSnapshot(forPath: "\(user.uid)/firstName").value as? String, + let last = snapshot.childSnapshot(forPath: "\(user.uid)/lastName").value as? String + else { completion(false); return } + + // Obtain current notification ID, update it + // Notification ID must always be optional, because users may not allow for it, and also because + // notification id is removed from database at sign out (and will thus be empty at sign in) + + if let notificationID = NotificationsController.currentDeviceNotificationID() { + self.userInformationRef.child("\(user.uid)/notificationID").setValue(notificationID) + UserState.currentUser.notificationID = notificationID + } + + // Set all properties of currentUser now that they have been unwrapped if needed + UserState.currentUser.displayPicture = "\(user.uid)/\(user.uid).JPG" + UserState.currentUser.email = email + UserState.currentUser.name = first + " " + last + UserState.currentUser.uid = user.uid + UserState.currentUser.username = username + + completion(true) + }) + } + + + /** + Writes current user's username into their information record and usernames registry in the database. This should + never change after set, so only call when creating account. + - parameters: + - username: Username chosen by the current user + - completion: Callback that is called upon successful completion + */ + public func setUsername(username: String, completion: (() -> ())) { + + guard let uid = Auth.auth().currentUser?.uid else { return } + + // Set current user, update username field in userInformation and registeredUsernames + UserState.currentUser.username = username + userInformationRef.child("\(uid)/username").setValue(username) + registeredUsernamesRef.child(username).setValue(uid) + completion() + } + + + /** + Writes the database storage path of an uploaded display picture to the current user's information record + - parameters: + - path: The path where the display picture has been successfully uploaded to + */ + public func setDisplayPicture(path: String) { + + guard let uid = Auth.auth().currentUser?.uid else { return } + + print("Setting displayPict: \(path)") + UserState.currentUser.displayPicture = path + userInformationRef.child("\(uid)/displayPicture").setValue(path) + } + + + /** Attempt to change the current user's email, if possible. This will update Auth, the database and UserState.currentUser + - parameters: + - email: The email to change to + - completion: A callback function that fires when email has been set, or discovers it cannot be done + - error: An optional error that will be set only if an error occured and email was not changed + */ + public func changeEmailAddress(to email: String, completion: @escaping (Error?) -> Void) { + + // Use the Firebase Auth function to allow changes to internal auth records + Auth.auth().currentUser?.updateEmail(to: email, completion: { error in + + if let error = error { + print("Error changing email: \(error.localizedDescription)") + completion(error) - guard let uid = Auth.auth().currentUser?.uid else { return } + } else { - print("Setting displayPict: \(path)") - UserState.currentUser.displayPicture = path - userInformationRef.child("\(uid)/displayPicture").setValue(path) - } - - - /** Attempt to change the current user's email, if possible. This will update Auth, the database and UserState.currentUser - - parameters: - - email: The email to change to - - completion: A callback function that fires when email has been set, or discovers it cannot be done - - error: An optional error that will be set only if an error occured and email was not changed - */ - public func changeEmailAddress(to email: String, completion: @escaping (Error?) -> Void) { + // Update local and database email records, then callback + self.userInformationRef.child(UserState.currentUser.uid).child("email").setValue(email) + UserState.currentUser.email = email + completion(nil) + } + }) + } + + + /** Attempt to change the current user's password, but will never store or record it directly + - parameters: + - password: The new password requested + - callback: Callback function that is called when Auth confirms it can or cannot perform change + - error: An optional Error object that will hold information if and when request fails + */ + public func changePassword(password: String, callback: @escaping (Error?) -> Void) { + + // Use the Firebase Auth function to allow changes to internal auth records + Auth.auth().currentUser?.updatePassword(to: password, completion: { error in + + if let error = error { - // Use the Firebase Auth function to allow changes to internal auth records - Auth.auth().currentUser?.updateEmail(to: email, completion: { error in - - if let error = error { - print("Error changing email: \(error.localizedDescription)") - completion(error) - - } else { - - // Update local and database email records, then callback - self.userInformationRef.child(UserState.currentUser.uid).child("email").setValue(email) - UserState.currentUser.email = email - completion(nil) - } - }) - } - - - /** Attempt to change the current user's password, but will never store or record it directly - - parameters: - - password: The new password requested - - callback: Callback function that is called when Auth confirms it can or cannot perform change - - error: An optional Error object that will hold information if and when request fails - */ - public func changePassword(password: String, callback: @escaping (Error?) -> Void) { + print("Error changing password: \(error.localizedDescription)") + callback(error) - // Use the Firebase Auth function to allow changes to internal auth records - Auth.auth().currentUser?.updatePassword(to: password, completion: { error in - - if let error = error { - - print("Error changing password: \(error.localizedDescription)") - callback(error) - - } else { callback(nil) } - }) - } - - - /** If possible, will set the attribute specified of the current user to the value provided. - - parameters: - - attribute: Attribute to change - - value: Value to set the attribute equal to - */ - public func changeCurrentUser(attribute: String, value: String) { - userInformationRef.child(UserState.currentUser.uid).child("\(attribute)").setValue(value) - } + } else { callback(nil) } + }) + } + + + /** If possible, will set the attribute specified of the current user to the value provided. + - parameters: + - attribute: Attribute to change + - value: Value to set the attribute equal to + */ + public func changeCurrentUser(attribute: String, value: String) { + userInformationRef.child(UserState.currentUser.uid).child("\(attribute)").setValue(value) + } } diff --git a/AtMe/AuthManager.swift b/AtMe/AuthManager.swift new file mode 100644 index 0000000..773badf --- /dev/null +++ b/AtMe/AuthManager.swift @@ -0,0 +1,19 @@ +// +// AuthManager.swift +// AtMe +// +// Created by Joel Rorseth on 2018-08-25. +// Copyright © 2018 Joel Rorseth. All rights reserved. +// + +import Foundation + +protocol AuthManager { + + func createAccount(email: String, firstName: String, lastName: String, password: String, + completion: @escaping ((Error?, String?) -> ())) + + func signIn(email: String, password: String, completion: @escaping ((Error?, Bool) -> ())) + + func signOut() throws +} diff --git a/AtMe/SettingsViewController.swift b/AtMe/SettingsViewController.swift index d0e6a2b..3c464e5 100644 --- a/AtMe/SettingsViewController.swift +++ b/AtMe/SettingsViewController.swift @@ -10,246 +10,248 @@ import UIKit import Firebase class SettingsViewController: UITableViewController, AlertController { + + lazy var databaseManager = DatabaseController() + lazy var authManager = AuthController() + + var currentAttributeChanging: Constants.UserAttribute = Constants.UserAttribute.none + var attributePrompt: String = "" + + @IBOutlet weak var userPictureImageView: UIImageView! + @IBOutlet weak var userDisplayNameLabel: UILabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var logoutCell: UITableViewCell! + + + /** Overridden method called after view controller's view is loaded into memory. */ + override func viewDidLoad() { + super.viewDidLoad() - lazy var databaseManager = DatabaseController() - lazy var authManager = AuthController() + // Add gesture recognizer to the profile picture UIImageView + let imageGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(SettingsViewController.promptImageSelection)) - var currentAttributeChanging: Constants.UserAttribute = Constants.UserAttribute.none - var attributePrompt: String = "" - - @IBOutlet weak var userPictureImageView: UIImageView! - @IBOutlet weak var userDisplayNameLabel: UILabel! - @IBOutlet weak var usernameLabel: UILabel! - @IBOutlet weak var logoutCell: UITableViewCell! + userPictureImageView.addGestureRecognizer(imageGestureRecognizer) + userPictureImageView.isUserInteractionEnabled = true + } + + + /** Overridden method called when view controller is soon to be added to view hierarchy. */ + override func viewWillAppear(_ animated: Bool) { + loadCurrentUserInformation() + logoutCell.backgroundColor = Constants.Colors.primaryDark + userPictureImageView.layer.masksToBounds = true + userPictureImageView.clipsToBounds = true + userPictureImageView.layer.cornerRadius = userPictureImageView.frame.size.width / 2 + } + + + /** Updates the UserState and database stored record of the current user's display picture (url). */ + func updateUserDisplayPicture() { - /** Overridden method called after view controller's view is loaded into memory. */ - override func viewDidLoad() { - super.viewDidLoad() - - // Add gesture recognizer to the profile picture UIImageView - let imageGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(SettingsViewController.promptImageSelection)) - - userPictureImageView.addGestureRecognizer(imageGestureRecognizer) - userPictureImageView.isUserInteractionEnabled = true - } + let uid = UserState.currentUser.uid + let url = "displayPictures/\(uid)/\(uid).JPG" + // Redownload into image into image view + // Although user may not even have profile picture yet, this method is safe and will clear if cached + databaseManager.reloadImage(into: userPictureImageView, from: url, completion: { error in + if let error = error { print("Error: \(error.localizedDescription)") } + else { print("Good") } + }) - /** Overridden method called when view controller is soon to be added to view hierarchy. */ - override func viewWillAppear(_ animated: Bool) { - loadCurrentUserInformation() - - logoutCell.backgroundColor = Constants.Colors.primaryDark - userPictureImageView.layer.masksToBounds = true - userPictureImageView.clipsToBounds = true - userPictureImageView.layer.cornerRadius = userPictureImageView.frame.size.width / 2 - } + // TODO: Refactor to remove assumption that path in userInfo signifies a valid profile picture + // Really we can just query database for this directly and let it fail if not found + authManager.setDisplayPicture(path: "\(uid)/\(uid).JPG") - /** Updates the UserState and database stored record of the current user's display picture (url). */ - func updateUserDisplayPicture() { - - let uid = UserState.currentUser.uid - let url = "displayPictures/\(uid)/\(uid).JPG" - - // Redownload into image into image view - // Although user may not even have profile picture yet, this method is safe and will clear if cached - databaseManager.reloadImage(into: userPictureImageView, from: url, completion: { error in - if let error = error { print("Error: \(error.localizedDescription)") } - else { print("Good") } - }) - - - // TODO: Refactor to remove assumption that path in userInfo signifies a valid profile picture - // Really we can just query database for this directly and let it fail if not found - authManager.setDisplayPicture(path: "\(uid)/\(uid).JPG") - - // Update current user stored display picture, reload image view - // loadCurrentUserInformation() - } + // Update current user stored display picture, reload image view + // loadCurrentUserInformation() + } + + + // TODO: Eventually show the actual current information greyed out in the table cells + /** Loads information about the current user into the view. */ + private func loadCurrentUserInformation() { + // Should never happen, app blocks until these have been set at login + userDisplayNameLabel.text = UserState.currentUser.name + usernameLabel.text = "@" + UserState.currentUser.username - // TODO: Eventually show the actual current information greyed out in the table cells - /** Loads information about the current user into the view. */ - private func loadCurrentUserInformation() { - - // Should never happen, app blocks until these have been set at login - userDisplayNameLabel.text = UserState.currentUser.name - usernameLabel.text = "@" + UserState.currentUser.username - - // UserState is assumed to hold only the relative path of display picture - // TODO: Change this in the future to full path, or better yet just eliminate it - guard let picture = UserState.currentUser.displayPicture else { - presentSimpleAlert(title: "Could Not Set Picture", message: Constants.Errors.displayPictureMissing, completion: nil) - return - } - - // Display picture may very well be nil if not set or loaded yet - // This is because display pictures are loaded asynchronously at launch - - databaseManager.downloadImage(into: userPictureImageView, - from: "displayPictures/\(picture)", completion: { error in - - if error != nil { return } - else { self.tableView.reloadData() } - }) + // UserState is assumed to hold only the relative path of display picture + // TODO: Change this in the future to full path, or better yet just eliminate it + guard let picture = UserState.currentUser.displayPicture else { + presentSimpleAlert(title: "Could Not Set Picture", message: Constants.Errors.displayPictureMissing, completion: nil) + return } - // TODO: In future update, this can maybe be refactored into custom UIImageView - /** Selector method which triggers a prompt for a UIImagePickerController. */ - @objc func promptImageSelection() { - - // Create picker, and set this controller as delegate - let picker = UIImagePickerController() - picker.delegate = self - picker.allowsEditing = true - - // Call AlertController method to display ActionSheet allowing Camera or Photo Library selection - // Use callback to set picker source type determined in the alert controller - - presentPhotoSelectionPrompt(completion: { (sourceType: UIImagePickerControllerSourceType?) in - - if let sourceType = sourceType { - picker.sourceType = sourceType - self.present(picker, animated: true, completion: nil) - } - }) - } + // Display picture may very well be nil if not set or loaded yet + // This is because display pictures are loaded asynchronously at launch + databaseManager.downloadImage(into: userPictureImageView, + from: "displayPictures/\(picture)", completion: { error in + + if error != nil { return } + else { self.tableView.reloadData() } + }) + } + + // TODO: In future update, this can maybe be refactored into custom UIImageView + /** Selector method which triggers a prompt for a UIImagePickerController. */ + @objc func promptImageSelection() { - /** Dismiss the keyboard from screen if currently displayed. */ - func dismissKeyboard() { - self.view.endEditing(true) - } + // Create picker, and set this controller as delegate + let picker = UIImagePickerController() + picker.delegate = self + picker.allowsEditing = true + + // Call AlertController method to display ActionSheet allowing Camera or Photo Library selection + // Use callback to set picker source type determined in the alert controller + presentPhotoSelectionPrompt(completion: { (sourceType: UIImagePickerControllerSourceType?) in + + if let sourceType = sourceType { + picker.sourceType = sourceType + self.present(picker, animated: true, completion: nil) + } + }) + } + + + /** Dismiss the keyboard from screen if currently displayed. */ + func dismissKeyboard() { + self.view.endEditing(true) + } + + + /** Determines actions to perform when a user chooses to logout. */ + func logout() { - /** Determines actions to perform when a user chooses to logout. */ - func logout() { + // Present a confirmation dialog to logout + let ac = UIAlertController(title: "Confirm Logout", message: Constants.Messages.confirmLogout, preferredStyle: .alert) + ac.view.tintColor = Constants.Colors.primaryDark + ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + ac.addAction(UIAlertAction(title: "Logout", style: .default, handler: { (action) in + + do { + // Attempt to logout, may throw error + // try Auth.auth().signOut() - // Present a confirmation dialog to logout - let ac = UIAlertController(title: "Confirm Logout", message: Constants.Messages.confirmLogout, preferredStyle: .alert) - ac.view.tintColor = Constants.Colors.primaryDark - ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) - ac.addAction(UIAlertAction(title: "Logout", style: .default, handler: { (action) in - - do { - // Attempt to logout, may throw error - try Auth.auth().signOut() - - // At this point, signOut() succeeded by not throwing any errors - // Let AuthController perform account sign out maintenance - - self.authManager.signOut() - self.performSegue(withIdentifier: Constants.Segues.unwindToSignInSegue, sender: self) - - } catch let error as NSError { - print("AtMe:: \(error.localizedDescription)") - } - })) + // At this point, signOut() succeeded by not throwing any errors + // Let AuthController perform account sign out maintenance - // Present the alert - self.present(ac, animated: true, completion: nil) - } + try self.authManager.signOut() + self.performSegue(withIdentifier: Constants.Segues.unwindToSignInSegue, sender: self) + + } catch let error as NSError { + self.presentSimpleAlert(title: "Error Signing Out", message: error.localizedDescription, + completion: nil) + print("AtMe:: \(error.localizedDescription)") + } + })) + // Present the alert + self.present(ac, animated: true, completion: nil) + } + + + /** Overridden method providing an opportunity for data transfer to destination view controller before segueing to it. */ + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - /** Overridden method providing an opportunity for data transfer to destination view controller before segueing to it. */ - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Blank out the 'Back' button for the view controller being presented + navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + + if segue.identifier == Constants.Segues.showPromptSegue { + if let destination = segue.destination as? PromptViewController { + var attributeIndex: Int = 0 - // Blank out the 'Back' button for the view controller being presented - navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + // Based on which section was selected, extract the correct UserAttribute from enum + // TODO: In future, should find cleaner solution for this - if segue.identifier == Constants.Segues.showPromptSegue { - if let destination = segue.destination as? PromptViewController { - var attributeIndex: Int = 0 - - // Based on which section was selected, extract the correct UserAttribute from enum - // TODO: In future, should find cleaner solution for this - - if (tableView.indexPathForSelectedRow!.section == 1) { - attributeIndex = tableView.indexPathForSelectedRow!.row + 1 - } else if (tableView.indexPathForSelectedRow!.section == 2) { - attributeIndex = tableView.indexPathForSelectedRow!.row + 3 - } - - destination.changingAttribute = Constants.UserAttribute(rawValue: attributeIndex)! - destination.changingAttributeName = Constants.UserAttributes.UserAttributeNames[attributeIndex] - } + if (tableView.indexPathForSelectedRow!.section == 1) { + attributeIndex = tableView.indexPathForSelectedRow!.row + 1 + } else if (tableView.indexPathForSelectedRow!.section == 2) { + attributeIndex = tableView.indexPathForSelectedRow!.row + 3 } + + destination.changingAttribute = Constants.UserAttribute(rawValue: attributeIndex)! + destination.changingAttributeName = Constants.UserAttributes.UserAttributeNames[attributeIndex] + } } + } } // MARK: - Image Picker Delegate Methods extension SettingsViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + // TODO: In future update, refactor + /** Called when media has been selected by the user in the image picker. */ + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) { - // TODO: In future update, refactor - /** Called when media has been selected by the user in the image picker. */ - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) { - - // Immediately dismiss for responsiveness - dismiss(animated: true) - - let uid = UserState.currentUser.uid - let path = "displayPictures/\(uid)/\(uid).JPG" - - // Extract the image after editing, upload to database as Data object - if let image = info[UIImagePickerControllerEditedImage] as? UIImage { - if let data = convertImageToData(image: image) { - - databaseManager.uploadImage(data: data, to: path, completion: { (error) in - if let error = error { - print("AtMe:: Error uploading display picture to Firebase. \(error.localizedDescription)") - return - } - - self.updateUserDisplayPicture() - }) - - } else { print("AtMe:: Error extracting image from camera source") } - } else { print("AtMe:: Error extracting edited UIImage from info dictionary") } - } + // Immediately dismiss for responsiveness + dismiss(animated: true) + let uid = UserState.currentUser.uid + let path = "displayPictures/\(uid)/\(uid).JPG" - /** Called if and when the user has cancelled the image picking operation. */ - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - dismiss(animated: true) - } + // Extract the image after editing, upload to database as Data object + if let image = info[UIImagePickerControllerEditedImage] as? UIImage { + if let data = convertImageToData(image: image) { + + databaseManager.uploadImage(data: data, to: path, completion: { (error) in + if let error = error { + print("AtMe:: Error uploading display picture to Firebase. \(error.localizedDescription)") + return + } + + self.updateUserDisplayPicture() + }) + + } else { print("AtMe:: Error extracting image from camera source") } + } else { print("AtMe:: Error extracting edited UIImage from info dictionary") } + } + + + /** Called if and when the user has cancelled the image picking operation. */ + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + dismiss(animated: true) + } } // MARK: - Table View extension SettingsViewController { + + /** Called when a given row / index path is selected in the table view. */ + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - /** Called when a given row / index path is selected in the table view. */ - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - // Opting to change editable user attributes prompts PromptViewController - if (indexPath.section == 1 || indexPath.section == 2) { - DispatchQueue.main.async { - self.performSegue(withIdentifier: Constants.Segues.showPromptSegue, sender: self) - } - } - - // Handle cache removal request - if (indexPath.section == 3) { - if (indexPath.row == 0) { - performSegue(withIdentifier: Constants.Segues.showBlockedUsersSegue, sender: nil) - } - - if (indexPath.row == 1) { - databaseManager.clearCachedImages() - presentSimpleAlert(title: "Cache Cleared", message: Constants.Messages.cacheClearedSuccess, completion: nil) - } - } - - // Initiate logout - if (indexPath.section == 4 && indexPath.row == 0) { - performSegue(withIdentifier: Constants.Segues.showLegalSegue, sender: nil) - } - - else if (indexPath.section == 4 && indexPath.row == 1) { - logout() - } + // Opting to change editable user attributes prompts PromptViewController + if (indexPath.section == 1 || indexPath.section == 2) { + DispatchQueue.main.async { + self.performSegue(withIdentifier: Constants.Segues.showPromptSegue, sender: self) + } + } + + // Handle cache removal request + if (indexPath.section == 3) { + if (indexPath.row == 0) { + performSegue(withIdentifier: Constants.Segues.showBlockedUsersSegue, sender: nil) + } + + if (indexPath.row == 1) { + databaseManager.clearCachedImages() + presentSimpleAlert(title: "Cache Cleared", message: Constants.Messages.cacheClearedSuccess, completion: nil) + } + } + + // Initiate logout + if (indexPath.section == 4 && indexPath.row == 0) { + performSegue(withIdentifier: Constants.Segues.showLegalSegue, sender: nil) + } + + else if (indexPath.section == 4 && indexPath.row == 1) { + logout() } + } }