A crucial type in the app is the CognitoStore
. There's only ever one instance of this type that lives throughout the life-cycle of the app. Everywhere in our app, a dev has access to this one instance through the sharedInstance
static stored property on the CognitoStore
.
Important to note is the init
function on this type. It sets everything up, most imporantly the userPool
stored property of type AWSCognitoIdentityUserPool
. This in turn will provide access to the AWSCognitoIdentityUser
that exists on the AWSCognitoIdentityUserPool
. This is done by calling currentUser()
on the AWSCognitoIdentityUserPool
instance.
We expose the AWSCognitoIdentityUser
in the form of a computed property named currentUser
.
Here's how accessing this currentUser
looks like throughout the app:
CognitoStore.sharedInstance.currentUser
NOTE: There might be some redundant calls going on in the init
function here. As well, I'm sure parts of it can be refactored, but as it stands.. it's getting the job done with no known issues.
import Foundation
import AWSCore
import AWSCognitoIdentityProvider
/// 🕵️ CognitoStore
final class CognitoStore: NSObject {
// MARK: - Singleton
static let sharedInstance = CognitoStore()
// MARK: - Properties
var credentialsProvider: AWSCognitoCredentialsProvider
var userPool: AWSCognitoIdentityUserPool
var configuration: AWSServiceConfiguration
var awsCredentials: AWSCredentialsType
var currentUser: AWSCognitoIdentityUser? {
return self.userPool.currentUser()
}
// The VC that starts the login/signup process should be set as the delegate.
var delegate: AWSCognitoIdentityInteractiveAuthenticationDelegate {
get {
return userPool.delegate
}
set {
userPool.delegate = newValue
}
}
// MARK: - Initializer(s)
private override init() {
// Placed this in a private init to make the CognitoStore a true singleton. Several of these calls
// can only be made once, as they configure settings for the entire AWS Cognito sdk, e.g.
// http://amzn.to/2riUP44.
configuration = AWSServiceConfiguration(region: .USEast1, credentialsProvider: nil)
#if DEBUG
awsCredentials = AWSCredentialsFactory.getCredentials(for: .staging)
#else
awsCredentials = AWSCredentialsFactory.getCredentials(for: .production)
#endif
let userPoolConfiguration = AWSCognitoIdentityUserPoolConfiguration(
clientId: awsCredentials.clientId,
clientSecret: awsCredentials.clientSecret,
poolId: awsCredentials.poolId)
AWSCognitoIdentityUserPool.register(with: configuration,
userPoolConfiguration: userPoolConfiguration,
forKey: awsCredentials.userPoolKey)
self.userPool = AWSCognitoIdentityUserPool(forKey: awsCredentials.userPoolKey)
self.credentialsProvider = AWSCognitoCredentialsProvider(
regionType: .USEast1,
identityPoolId: awsCredentials.identityPoolId,
identityProviderManager: self.userPool)
configuration = AWSServiceConfiguration(region: .USEast1,
credentialsProvider: self.credentialsProvider)
AWSServiceManager.default().defaultServiceConfiguration = configuration
super.init()
}
}
The first thing we do (currently happening in FinalLoggedOutHomeViewController
(this name needs to be refactored) once the app initially launches) is to make the check to see if we have a current user, and if so.. check to see if they need to finish registration.
When the app first launches, we call through to a method doesCurrentUserNeedToFinishRegistration()
, which is implemented by the HomeViewControllerViewModel
:
NOTE: This method signature is better served to be a Single
.
func doesCurrentUserNeedToFinishRegistration() -> Observable<Bool> {
guard let currentUser = currentUser else {
return Observable.just(false)
}
switch currentUser.confirmedStatus {
/* A confirmed and unconfirmed status does not need to be handled. In the issue we're solving
where a user might have confirmed upon registration but didn't fill out the profile information
necessary to complete registration, confirmed and uncofirmed does not get called. */
case .confirmed, .unconfirmed:
return Observable.just(false)
/* An unknown status comes down (in this scenario) when during the registration flow, a user
had completed part 1 and part 2 of registration (entering in the confirmation code) but then
didn't complete filling out their profile information which is required to finalize registration.
But this isn't the ONLY scenario where we could find ourselves in this unknown enum. */
case .unknown:
return Observable.create { [unowned self] observer in
self.serviceProvider.patientService.fetchCurrentPatient()
.observeOn(MainScheduler.instance)
.subscribe(onNext: nil, onError: { error in
/* If the error is of type ObjectMapper.MapError, it means that through our call
to fetchCurrentPatient() , we were unable to map the response in creating an instance
of Patient. To get this far in the process and being unable to map to a Patient would mean that
the user DIDN'T complete registration because there are missing parameters. */
observer.onNext(error is ObjectMapper.MapError)
observer.onCompleted()
}, onCompleted: {
observer.onNext(false)
observer.onCompleted()
})
.disposed(by: self.bag)
return Disposables.create()
}
}
}
Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController
. Those types are the RegistrationViewControllerStepOne
and RegistrationVCStepOneViewModel
.
RegistrationViewControllerStepOne
If a user enters in a valid E-mail, Phone number and password and then hits register, the following will happen:
BetterProgressHUD.show()
viewModel.signUpUser(email: email, password: password)
.observeOn(MainScheduler.instance)
.subscribe { [weak self] event in
switch event {
case .next(let signUpResponse):
self?.handle(signUpResponse: signUpResponse)
case .error(let error):
self?.viewModel.retrieveUser(with: email)
self?.handle(signUpUserError: error as NSError)
case .completed:
break
}
}
.disposed(by: viewModel.bag)
The following methods exist as extensions on the RegistrationViewControllerStepOne
:
// MARK: - ⬆️ Sign Up
fileprivate extension RegistrationViewControllerStepOne {
/// Handles the instance of CognitoSignUpResponse we get back from the
/// request that is attempting to sign in the user after they inputted in
/// an e-mail, phone-number and password
///
/// - Parameter signUpResponse: CognitoSignUpResponse instance from request
func handle(signUpResponse: CognitoSignupResponse) {
viewModel.user = signUpResponse.user
switch signUpResponse.user.confirmedStatus {
case .confirmed, .unknown:
signInUser()
case .unconfirmed:
continueToStepTwoInRegistration()
}
}
func handle(signUpUserError error: NSError) {
guard let awsError = AWSCognitoIdentityProviderErrorType(rawValue: error.code) else {
// TODO: Handle the fact that an error was thrown but we don't know the type of error.
// TODO: Before displaying error, Dismiss Alert
return
}
switch awsError {
case .expiredCode:
handleUserWithExpiredCode()
case .usernameExists:
signInUser()
case .userNotConfirmed:
continueToStepTwoInRegistration()
default:
// TODO: display general error here.
// TODO: Before displaying error, Dismiss Alert
break
}
}
func handleUserWithExpiredCode() {
viewModel.resendConfirmation()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
BetterProgressHUD.dismiss()
self?.continueToStepTwoInRegistration()
}, onError: { error in
// TODO: Display Error
// TODO: Before displaying error, Dismiss Alert
log.debug(error)
})
.disposed(by: viewModel.bag)
}
func signInUser() {
guard let email = emailUserInputView.text?.lowercased(),
let password = passwordUserInputView.text else {
BetterProgressHUD.dismiss()
BetterAlert.display(config: .registrationNotComplete)
return
}
viewModel.signInUser(email: email, password: password)
.observeOn(MainScheduler.instance)
.subscribe { [weak self] event in
switch event {
case .next:
self?.handleUserSuccessfullySigningIn() // ✅
case .error(let error):
BetterProgressHUD.dismiss()
self?.handle(signInError: error as NSError)
case .completed:
break
}
}
.disposed(by: viewModel.bag)
}
func handle(signInError error: NSError) {
guard let awsError = AWSCognitoIdentityProviderErrorType(rawValue: error.code) else {
// TODO: Handle the fact that an error was thrown but we don't know the type of error.
return
}
switch awsError {
case .userNotConfirmed:
continueToStepTwoInRegistration()
case .invalidPassword, .notAuthorized:
BetterAlert.display(config: .incorrectPasswordOnRegistration)
case .userLambdaValidation:
BetterAlert.display(config: .emailExistsOnClinic)
default:
// TODO: display general error here.
break
}
}
}
// MARK: - ➡️ Signing In
fileprivate extension RegistrationViewControllerStepOne {
// ✅
func handleUserSuccessfullySigningIn() {
viewModel.fetchCurrentPatient()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
BetterProgressHUD.dismiss()
LoggedInState.shared.userHasLoggedIn()
NewAppStorage().didCompleteCognitoSignup = true
self?.handleSuccessfullyFetchingCurrentPatient() // ✅
}, onError: { [weak self] error in
self?.handleFetchCurrentPatientError(error) // ⚠️
})
.disposed(by: viewModel.bag)
}
// ✅
func handleSuccessfullyFetchingCurrentPatient() {
let config = BetterAlertConfig.successfullSignInAfterSignUpRequst(email: emailUserInputView.text)
BetterAlert.display(config: config, leftButtonAction: nil) { [weak self] in
self?.dismiss(animated: true, completion: nil)
}
}
// ⚠️
func handleFetchCurrentPatientError(_ error: Error) {
switch error {
case is ResponseError:
if let responseError = error as? ResponseError, let success = responseError.success,
!success, let message = responseError.message, message == "Patient Not Found" {
createEmptyPatientAndMoveToFinalRegistration()
} else {
BetterProgressHUD.dismiss()
let additionalText = (error as! ResponseError).message ?? ""
let bodyText = "We're unable to sign you in with the provided e-mail, please try again later. " + additionalText
let config = BetterAlertConfig(titleText: "Sign-in Error", bodyText: bodyText)
BetterAlert.display(config: config, rightButtonAction: {
MainTabBarStore.shared.dispatch(action: .logout(displayAlert: false))
})
}
case is ObjectMapper.MapError:
BetterProgressHUD.dismiss()
moveToFinalRegistration()
default:
BetterProgressHUD.dismiss()
let config = BetterAlertConfig.unableToFetchCurrentPatient(email: emailUserInputView.text,
message: error.localizedDescription)
BetterAlert.display(config: config, rightButtonAction: {
MainTabBarStore.shared.dispatch(action: .logout(displayAlert: false))
})
}
}
func createEmptyPatientAndMoveToFinalRegistration() {
viewModel.createEmptyPatient()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
BetterProgressHUD.dismiss()
self?.moveToFinalRegistration()
}, onError: { error in
BetterProgressHUD.dismiss()
BetterAlert.display(config: .unableToCreateEmptyUser(error))
})
.disposed(by: viewModel.bag)
}
func moveToFinalRegistration() {
let finalRegisterViewModel = FinalRegistrationViewModel(phone: phoneNumberUserInputView.text ?? "")
let vc = FinalRegistrationViewController.makeViewController(viewModel: finalRegisterViewModel)
navigationController?.pushViewController(vc, animated: true)
}
}
The RegistrationVCStepOneViewModel is used by the RegistrationViewControllerStepOne
:
final class RegistrationVCStepOneViewModel: ViewModel {
var user: CognitoUser?
fileprivate let cognitoStore = CognitoStore.sharedInstance
}
// MARK: - Registration
extension RegistrationVCStepOneViewModel {
func signUpUser(email: String, password: String) -> Observable<CognitoSignupResponse> {
return serviceProvider.cognitoService.createUser(in: cognitoStore.userPool,
with: email,
password: password)
}
func signInUser(email: String, password: String) -> Observable<CognitoUserSession> {
guard let user = user else {
let error: AWSCognitoIdentityProviderErrorType = .unknown
let nsError = NSError(domain: "AWS", code: error.rawValue, userInfo: nil)
return Observable.error(nsError)
}
return serviceProvider.cognitoService.signInNow(user: user, username: email, password: password)
}
func resendConfirmation() -> Observable<CognitoConfirmResendResponse> {
guard let user = user else {
let error: AWSCognitoIdentityProviderErrorType = .unknown
let nsError = NSError(domain: "AWS", code: error.rawValue, userInfo: nil)
return Observable.error(nsError)
}
return serviceProvider.cognitoService.resendConfirmation(user)
}
func createEmptyPatient() -> Observable<Int> {
return serviceProvider.patientService.createEmptyPatient()
}
func fetchCurrentPatient() -> Observable<Patient> {
return serviceProvider.patientService.fetchCurrentPatient()
}
func retrieveUser(with email: String) {
user = cognitoStore.userPool.getUser(email)
}
}
// MARK: - Navigation
fileprivate extension RegistrationViewControllerStepOne {
/// This method is only called if a user has successfuly registered. If they did,
/// this method will fire off where we create an instance of the RegistrationViewControllerStepTwos
/// viewModel and pass it along to our instance of RegistrationViewControllerStepTwo to then
/// push onto the navigation stack
func continueToStepTwoInRegistration() {
guard let email = emailUserInputView.text,
let password = passwordUserInputView.text,
let phone = phoneNumberUserInputView.text,
let user = viewModel.user else {
// TODO: I don't think we can ever be in this scenario, but display alert anyway
// TODO: Dismiss Progress HUD before displaying alert
return
}
BetterProgressHUD.dismiss()
let stepTwoViewModel = RegistrationVCStepTwoViewModel(email: email,
password: password,
phone: phone,
user: user)
let vc = RegistrationViewControllerStepTwo.makeViewController(viewModel: stepTwoViewModel)
navigationController?.pushViewController(vc, animated: true)
}
}
Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController
. Those types are the RegistrationViewControllerStepTwo
and RegistrationVCStepTwoViewModel
.
Run through the code to see the path taken after a user enters in a validation code and then taps "CONFIRM":
// MARK: - Confirming The User
fileprivate extension RegistrationViewControllerStepTwo {
func confirmUser() {
guard let confirmationCode = confirmationCodeUserInputView.text else {
BetterAlert.display(config: .inputConfirmationCode)
return
}
BetterProgressHUD.show()
NewAppStorage().didCompleteCognitoSignup = true
viewModel.confirmUser(with: confirmationCode)
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] patientID in
BetterProgressHUD.dismiss()
self?.viewModel.patientID = patientID
NewAppStorage().didCompleteCognitoSignup = false
self?.proceedToFinalStageOfRegistration()
}, onError: { [weak self] error in
BetterProgressHUD.dismiss()
self?.displayConfirmError(error)
NewAppStorage().didCompleteCognitoSignup = false
})
.disposed(by: viewModel.disposeBag)
}
func resendConfirmationCode() {
BetterProgressHUD.show()
NewAppStorage().didCompleteCognitoSignup = true
viewModel.resendConfirmation()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
BetterProgressHUD.dismiss()
NewAppStorage().didCompleteCognitoSignup = false
self?.displayResentConfirmationAlert()
}, onError: { [weak self] error in
BetterProgressHUD.dismiss()
self?.displayResentError(error)
NewAppStorage().didCompleteCognitoSignup = false
})
.disposed(by: viewModel.disposeBag)
}
func proceedToFinalStageOfRegistration() {
removeSelfAsObserver()
let finalRegistrationViewModel = FinalRegistrationViewModel(phone: viewModel.phone)
let finalRegistrationVC = FinalRegistrationViewController.makeViewController(viewModel: finalRegistrationViewModel)
navigationController?.pushViewController(finalRegistrationVC, animated: true)
}
func displayResentConfirmationAlert() {
BetterAlert.display(config: .resentConfirmationCode(email: viewModel.email))
}
func displayConfirmError(_ error: Error) {
let nsError = error as NSError
guard nsError.domain.contains("AWS"),
let awsError = AWSCognitoIdentityProviderErrorType(rawValue: nsError.code) else {
BetterAlert.display(config: .confirmError(error))
return
}
switch awsError {
case .notAuthorized:
BetterAlert.display(config: .forgotOnPasswordAfterConfirmingAccount, leftButtonAction: nil, rightButtonAction: { [weak self] in
self?.navigationController?.popViewController(animated: true)
})
default:
BetterAlert.display(config: .confirmError(error))
}
}
func displayResentError(_ error: Error) {
BetterAlert.display(config: .resentError(error))
}
}
The RegistrationVCStepTwoViewModel it's making use of:
final class RegistrationVCStepTwoViewModel {
let email: String
let password: String
let phone: String
let user: CognitoUser
let disposeBag = DisposeBag()
var patientID: Int?
fileprivate let cognitoStore = CognitoStore.sharedInstance
fileprivate let serviceProvider = ServiceProvider()
init(email: String, password: String, phone: String, user: CognitoUser) {
self.email = email
self.password = password
self.phone = phone
self.user = user
}
func confirmUser(with confirmationCode: String) -> Observable<Int> {
return serviceProvider.cognitoService.confirm(user: user, with: confirmationCode)
.flatMap { (_) -> Observable<CognitoUserSession> in
return self.serviceProvider.cognitoService.signInNow(user: self.user,
username: self.email,
password: self.password)
}
.flatMap { (_) -> Observable<Int> in
return self.serviceProvider.patientService.createEmptyPatient()
}
}
func resendConfirmation() -> Observable<CognitoConfirmResendResponse> {
return serviceProvider.cognitoService.resendConfirmation(user)
}
}
Instead of providing a written step-by-step of how we handle this process, I've included the most important code snippets from the two types that power this UIViewController
. Those types are the FinalRegistrationViewController
and FinalRegistrationViewModel
.
After a user inputs the necessary info and then taps REGISTER:
// MARK: - Updating Patient
fileprivate extension FinalRegistrationViewController {
func createPatient() {
guard let date = dateOfBirthInputView.date,
let firstName = firstNameInputView.text,
let lastName = lastNameInputView.text,
let genderCharacter = genderInputView.text?.first else {
BetterAlert.display(config: .registrationNotComplete)
return
}
let dictionary: [String: Any] = [
"firstName": firstName,
"lastName": lastName,
"phone": viewModel.phone,
"gender": String(genderCharacter),
"birthday": createBirthday(from: date),
"acceptPatientTOS": termsOfUseAgreementView.doesAgree,
"acceptPatientPrivacy": privacyPolicyAgreementView.doesAgree,
"acceptHIPAA": hippaAuthorizationAgreementView.doesAgree
]
updatePatient(with: dictionary)
}
func createBirthday(from date: Date) -> String {
return DateFormatter.bptBirthdayDateFormatter.string(from: date)
}
func updatePatient(with dictionary: [String: Any]) {
BetterProgressHUD.show()
viewModel.updatePatient(with: dictionary)
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] patient in
BetterProgressHUD.dismiss()
self?.handleOnNext(patient: patient)
}, onError: { [weak self] error in
BetterProgressHUD.dismiss()
self?.displayErrorAlert(error: error)
})
.disposed(by: viewModel.disposeBag)
}
func handleOnNext(patient: Patient) {
// TODO: Make these both static or both not
NewAppStorage.persistPatient(patient)
NewAppStorage().didCompleteCognitoSignup = true
viewModel.serviceProvider.analyticsService.logUserId(from: patient)
LoggedInState.shared.userHasLoggedIn()
BetterAlert.display(config: .completedRegistration, leftButtonAction: nil) { [weak self] in
self?.dismiss(animated: true, completion: {
NotificationCenter.default.post(name: .loggedInWithUI, object: nil)
})
}
}
}
The FinalRegistrationViewModel:
// MARK: - FinalRegistrationViewModelType
protocol FinalRegistrationViewModelType {
// MARK: - ViewModel Inputs
var patientDictionary: JSONDictionary { get }
var locationDictionary: JSONDictionary { get }
var imageDataString: Base64String { get set }
var phone: String { get }
var disposeBag: DisposeBag { get }
var navigationTitle: String { get }
var serviceProvider: ServiceProvider { get }
// MARK: - ViewModel Outputs
func updatePatient(with patientDictionary: JSONDictionary) -> Observable<Patient>
}
class FinalRegistrationViewModel: FinalRegistrationViewModelType {
// MARK: - Properties
let phone: String
let disposeBag = DisposeBag()
let serviceProvider = ServiceProvider()
var patientDictionary: JSONDictionary = [:]
var locationDictionary: JSONDictionary = [:]
var imageDataString: Base64String = ""
let navigationTitle = "Register (Step 3 of 3)"
init(phone: String) {
self.phone = phone
}
func updatePatient(with patientDictionary: JSONDictionary) -> Observable<Patient> {
return serviceProvider.patientService.updatePatient(with: patientDictionary)
.flatMap { patient in
return self.serviceProvider.imageService.uploadBase64(resourceId: patient.patientId,
base64String: self.imageDataString,
imageUploadType: .userAvatar)
}
.flatMap { _ in
return self.serviceProvider.patientService.fetchCurrentPatient()
}
}
}