Enable Multi-User Mode for iOS Application
Prerequisites
- SAP BTP SDK for iOS 9.1 or higher
- Set Up SAP BTP SDK for iOS
- Created Your First App using SAP BTP SDK Assistant for iOS
- How to create a native iOS application that supports multiple users on the same device
- Step 1
An air carrier organisation uses an iOS application built using SAP BTP SDK for iOS to keep a track of an aircraft’s vital information (Fuel Level, Tyre Pressure, etc.) before each flight. The application must support offline use-case to comply with the network regulations at the airport. Since the airline has flights departing round the clock, it deploys ground staff in three 8-hour shifts. To maximise efficiency, the organisation wants ground staff to share mobile devices.
The ground staff members want a solution that is reliable even in the absence of network. They also aren’t keen on logging out and logging in every time a shift ends, as they believe this could lead to erroneous data.
In this tutorial, you will learn how to enhance your SAP BTP SDK for iOS Assistant generated application to create an offline enabled application that supports multiple users.
Log in to complete tutorial - Step 2
In your mobile services account, click Mobile Applications → Native/Hybrid →
. Under Assigned Features, click Mobile Settings Exchange.
Assigned features can be found under the Info tab.
Under Shared Devices section, enable the Allow Upload of Pending Changes from Previous User (Enable Multiple User Mode): checkbox.
You must enable this checkbox if you are building an offline capable multi-user application.
- Step 3
In the given scenario any pending changes done by a user should be uploaded before another user signs in. Thus, you must configure trust to enable upload of pending changes from previous users of mobile applications.
Open your mobile services account.
In the Side Navigation Bar, click Settings → Security.
Click Metadata Download.
NoAuth
authentication type is not supported. Even if multi-user mode is turned on, an application using theNoAuth
authentication type will revert to single user mode.Go to your sub account on SAP BTP.
In the Side Navigation Bar, click Security → Trust Configuration.
Click New Trust Configuration.
Click Upload, and select the XML file downloaded in the earlier step.
Enter a name, and click Save.
Log in to complete tutorial - Step 4
In Xcode, Open
AppParameters.plist
.Ensure that you have completed the prerequisites before starting this step.
Add a new parameter by providing the following key/value.
Key Type Value User Mode String Multiple
Log in to complete tutorial - Step 5
Open
AppDelegate.swift
.Add a public variable to track user change status.
swiftCopypublic var userDidChange = false
Replace the
applicationWillEnterForeground
function with the given code to trigger multi-user flow.SwiftCopyfunc applicationWillEnterForeground(_: UIApplication) { // Triggers to show the multi-user passcode screen OnboardingSessionManager.shared.unlock() { error in guard let error = error else { if self.userDidChange { self.afterOnboard() self.userDidChange = false } return } self.onboardingErrorHandler?.handleUnlockingError(error) } }
Replace the
initializeOnboarding
function with the following code to configureOnboardingSessionManager
which includesMultiUserOnboardingIDManager
.SwiftCopyfunc initializeOnboarding() { let presentationDelegate = ApplicationUIManager(window: self.window!) self.onboardingErrorHandler = OnboardingErrorHandler() self.sessionManager = OnboardingSessionManager(presentationDelegate: presentationDelegate, flowProvider: self.flowProvider, onboardingIDManager: MultiUserOnboardingIDManager(), delegate: self.onboardingErrorHandler) presentationDelegate.showSplashScreenForOnboarding { _ in } self.onboardUser() }
Log in to complete tutorial - Step 6
Open
OnboardingFlowProvider
.Add a new extension for
FUIPasscodeControllerDelegate
by pasting the following code block for multi-user callbacks:SwiftCopyextension OnboardingFlowProvider: FUIPasscodeControllerDelegate { public func shouldTryPasscode(_ passcode: String, forInputMode inputMode: FUIPasscodeInputMode, fromController passcodeController: FUIPasscodeController) throws { print("Called shouldTryPasscode") } public func shouldResetPasscode(fromController passcodeController: FUIPasscodeController) { print("Called shouldResetPasscode") } public func addNewUser(_ passcodeController: FUIPasscodeController) { print("Called addNewUser") AppDelegate.shared.userDidChange = true } public func switchUser(_ newUserId: String, passcodeController: FUIPasscodeController) { print("Called switchUser") AppDelegate.shared.userDidChange = true } }
Log in to complete tutorial - Step 7
In
OnboardingFlowProvider
, add a new function calledconfiguredStoreManagerStep
.SwiftCopyprivate func configuredStoreManagerStep() -> StoreManagerStep { let st = StoreManagerStep() st.userPasscodeControllerDelegate = self return st }
Replace the calls for
StoreManagerStep()
withconfiguredStoreManagerStep()
inonboardingSteps
andrestoringSteps
to onboard a new user or restore the session of a previously logged in user.SwiftCopypublic var onboardingSteps: [OnboardingStep] { return [ self.configuredWelcomeScreenStep(), CompositeStep(steps: SAPcpmsDefaultSteps.configuration), OAuth2AuthenticationStep(), CompositeStep(steps: SAPcpmsDefaultSteps.settingsDownload), CompositeStep(steps: SAPcpmsDefaultSteps.applyDuringOnboard), self.configuredUserConsentStep(), self.configuredDataCollectionConsentStep(), configuredStoreManagerStep(), ODataOnboardingStep(), ] } public var restoringSteps: [OnboardingStep] { return [ configuredStoreManagerStep(), self.configuredWelcomeScreenStep(), CompositeStep(steps: SAPcpmsDefaultSteps.configuration), OAuth2AuthenticationStep(), CompositeStep(steps: SAPcpmsDefaultSteps.settingsDownload), CompositeStep(steps: SAPcpmsDefaultSteps.applyDuringRestore), self.configuredDataCollectionConsentStep(), ODataOnboardingStep(), ] }
Replace
onboardingSteps
&restoringSteps
with the given code.
Log in to complete tutorial - Step 8
Open
ODataControlling.Swift
.Import
SAPOfflineOdata
SwiftCopyimport SAPOfflineOData
Add a new protocol for
configureOData
function:SwiftCopyfunc configureOData(sapURLSession: SAPURLSession, serviceRoot: URL, onboardingID: UUID, offlineParameters: OfflineODataParameters) throws
Open
ODataOnboardingStep.Swift
.Import
SAPOfflineOdata
SwiftCopyimport SAPOfflineOData
Add offline store name’s key value.
SwiftCopylet offlineStoreNameKey: String = "SAP.OfflineOData.MultiUser"
Add a new function
offlineStoreID
that generates a UUID if the offline store name is nil.SwiftCopyprivate func offlineStoreID() -> UUID { var offlineStoreName: String? = UserDefaults.standard.value(forKey: self.offlineStoreNameKey) as? String if offlineStoreName == nil { offlineStoreName = UUID().uuidString UserDefaults.standard.set(offlineStoreName, forKey: self.offlineStoreNameKey) } let offlineStoreNameID: UUID = UUID(uuidString: offlineStoreName!)! return offlineStoreNameID }
Replace the
reset
function with the following code to pass theofflineStoreID()
.SwiftCopypublic func reset(context: OnboardingContext, completionHandler: @escaping () -> Void) { defer { completionHandler() } do { try ESPMContainerOfflineODataController.removeStore(for: offlineStoreID()) } catch { self.logger.error("Remove Offline Store failed", error: error) } }
Add a new function
getOfflineODataParameters
to determine the user who is logging in.SwiftCopyprivate func getOfflineODataParameters(using context: OnboardingContext, completionnHandler: @escaping (OfflineODataParameters) -> Void) { var currentUser: String? = nil var forceUploadOnUserSwitch: Bool = false var storeEncryptionKey: String? = nil if let onboardedUser = UserManager().get(forKey: context.onboardingID), let userId = onboardedUser.infoString { currentUser = userId storeEncryptionKey = try? context.credentialStore.get(String.self, for: EncryptionConfigLoader.encryptionKeyID) if let enabled = (context.info[.sapcpmsSharedDeviceSettings] as? SAPcpmsSharedDeviceSettings)?.allowUploadPendingChangesFromPreviousUser { forceUploadOnUserSwitch = enabled } let offlineParameters = OfflineODataParameters() offlineParameters.currentUser = currentUser offlineParameters.forceUploadOnUserSwitch = forceUploadOnUserSwitch offlineParameters.storeEncryptionKey = storeEncryptionKey completionnHandler(offlineParameters) } else { fatalError("Failed to fetch user information!") } }
Replace the
configureOData
function with the following code to determine the user mode, and configure parameters accordingly.SwiftCopyprivate func configureOData(using context: OnboardingContext, completionHandler: @escaping (OnboardingResult) -> Void) { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var offlineParameters: OfflineODataParameters = OfflineODataParameters() if UserManager.userMode == .multiUser { self.getOfflineODataParameters(using: context) { parameters in offlineParameters = parameters semaphore.signal() } } semaphore.wait() let banner = topBanner() let group = DispatchGroup() var odataControllers = [String: ODataControlling]() let destinations = FileConfigurationProvider("AppParameters").provideConfiguration().configuration["Destinations"] as! NSDictionary let eSPMContainerOfflineODataDelegateSample = OfflineODataDelegateSample(for: "ESPMContainer", with: banner) odataControllers[ODataContainerType.eSPMContainer.description] = ESPMContainerOfflineODataController(delegate: eSPMContainerOfflineODataDelegateSample) for (odataServiceName, odataController) in odataControllers { group.enter() let destinationId = destinations[odataServiceName] as! String // Adjust this path so it can be called after authentication and returns an HTTP 200 code. This is used to validate the authentication was successful. let configurationURL = URL(string: (context.info[.sapcpmsSettingsParameters] as! SAPcpmsSettingsParameters).backendURL.appendingPathComponent(destinationId).absoluteString)! do { try odataController.configureOData(sapURLSession: context.sapURLSession, serviceRoot: configurationURL, onboardingID: offlineStoreID(), offlineParameters: offlineParameters) let connectivityStatus = ConnectivityUtils.isConnected() self.logger.info("Network connectivity status: \(connectivityStatus)") odataController.openOfflineStore(synchronize: connectivityStatus) { error in if let error = error { completionHandler(.failed(error)) return } self.controllers[odataServiceName] = odataController group.leave() } } catch { completionHandler(.failed(error)) } } group.notify(queue: .main) { completionHandler(.success(context)) } }
Log in to complete tutorial - Step 9
Open
ESPMContainerOfflineODataController.Swift
Update the error cases to include
syncFailed
SwiftCopypublic enum Error: Swift.Error { case cannotCreateOfflinePath case storeClosed case syncFailed }
Replace the
configureOData
function with the following code to acceptOfflineODataParameters
.SwiftCopypublic func configureOData(sapURLSession: SAPURLSession, serviceRoot: URL, onboardingID: UUID, offlineParameters: OfflineODataParameters = OfflineODataParameters()) throws { offlineParameters.enableRepeatableRequests = true // Configure the path of the Offline Store let offlinePath = try ESPMContainerOfflineODataController.offlineStorePath(for: onboardingID) try FileManager.default.createDirectory(at: offlinePath, withIntermediateDirectories: true) offlineParameters.storePath = offlinePath // Setup an instance of delegate. See sample code below for definition of OfflineODataDelegateSample class. let offlineODataProvider = try! OfflineODataProvider(serviceRoot: serviceRoot, parameters: offlineParameters, sapURLSession: sapURLSession, delegate: delegate) try configureDefiningQueries(on: offlineODataProvider) self.dataService = ESPMContainer(provider: offlineODataProvider) }
Replace the
openOfflineStore
function with the following code to catch the sync error.SwiftCopypublic func openOfflineStore(synchronize: Bool, completionHandler: @escaping (Swift.Error?) -> Void) { if !self.isOfflineStoreOpened { // The OfflineODataProvider needs to be opened before performing any operations. self.dataService.open { error in if let error = error { self.logger.error("Could not open offline store.", error: error) if (error.code == -10425 || error.code == -10426) { completionHandler(Error.syncFailed) } else { completionHandler(error) } return } self.isOfflineStoreOpened = true self.logger.info("Offline store opened.") if synchronize { // You might want to consider doing the synchronization based on an explicit user interaction instead of automatically synchronizing during startup self.synchronize(completionHandler: completionHandler) } else { completionHandler(nil) } } } else if synchronize { // You might want to consider doing the synchronization based on an explicit user interaction instead of automatically synchronizing during startup self.synchronize(completionHandler: completionHandler) } else { completionHandler(nil) } }
Log in to complete tutorial - Step 10
Open
OnboardingErrorHandler.swift
.Replace the
onboardingController
function with the following code to handle application specific error handling.swiftCopy
public func onboardingController(_ controller: OnboardingControlling, didFail flow: OnboardingFlow, with error: Error, completionHandler: @escaping (OnboardingErrorDisposition) -> Void) { switch flow.flowType { case .onboard, .resetPasscode: self.onboardFailed(with: error, completionHandler: completionHandler) case .restore: self.restoreFailed(with: error, controller: controller, context: flow.context, completionHandler: completionHandler) default: completionHandler(.retry) } }Replace
onboardFailed
function with the given code to handle duplicate user in case of add user, user mismatch in case of reset passcode andauthenticationManager
related errors.swiftCopyprivate func onboardFailed(with error: Error, completionHandler: @escaping (OnboardingErrorDisposition) -> Void) { switch error { case WelcomeScreenError.demoModeRequested: completionHandler(.stop(error)) return default: showAlertWith(error: error) } func showAlertWith(error: Error) { let alertController = UIAlertController( title: LocalizedStrings.Onboarding.failedToLogonTitle, message: error.localizedDescription, preferredStyle: .alert ) switch error { case SAPcpmsAuthenticationManagerError.userSwitch(from: let fromId, to: let toId): if let _ = UserManager().get(forKey: UUID(uuidString: toId)!) { alertController.addAction(UIAlertAction(title: "Restore", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: toId, completionHandler: completionHandler) }) } else { alertController.addAction(UIAlertAction(title: "Onboard", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: nil, completionHandler: completionHandler) }) } case UserManagerError.userAlreadyExists(with: let onboardingID): alertController.addAction(UIAlertAction(title: "Restore", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: onboardingID, completionHandler: completionHandler) }) case UserManagerError.userMismatch(with: let id): if let idNotNil = id { alertController.addAction(UIAlertAction(title: "Restore", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: idNotNil, completionHandler: completionHandler) }) } else { alertController.addAction(UIAlertAction(title: "Onboard", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: nil, completionHandler: completionHandler) }) } default: alertController.addAction(UIAlertAction(title: LocalizedStrings.Onboarding.retryTitle, style: .default) { _ in completionHandler(.retry) }) } DispatchQueue.main.async { guard let topViewController = ModalUIViewControllerPresenter.topPresentedViewController() else { fatalError("Invalid UI state") } topViewController.present(alertController, animated: true) } } }
Replace
restoreFailed
function with the given code to handle multi-user errors during restore.swiftCopyprivate func restoreFailed(with error: Error, controller: OnboardingControlling, onboardingID: UUID?, completionHandler: @escaping (OnboardingErrorDisposition) -> Void) { let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .alert) switch error { case StoreManagerError.cancelPasscodeEntry, StoreManagerError.skipPasscodeSetup, StoreManagerError.resetPasscode: resetOnboarding(onboardingID, controller: controller, completionHandler: completionHandler) return case StoreManagerError.passcodeRetryLimitReached: alertController.title = LocalizedStrings.Onboarding.passcodeRetryLimitReachedTitle alertController.message = LocalizedStrings.Onboarding.passcodeRetryLimitReachedMessage case SAPcpmsAuthenticationManagerError.userSwitch(from: let fromId, to: let toId): if let _ = UserManager().get(forKey: UUID(uuidString: toId)!) { alertController.addAction(UIAlertAction(title: "Restore", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: toId, completionHandler: completionHandler) }) } else { alertController.addAction(UIAlertAction(title: "Onboard", style: .default) { _ in self.switchToDuplicateUserWith(onboardingID: nil, completionHandler: completionHandler) }) } case ApplicationVersioningError.inactive: alertController.title = LocalizedStrings.Onboarding.failedToLogonTitle alertController.message = error.localizedDescription alertController.addAction(UIAlertAction(title: LocalizedStrings.Onboarding.retryTitle, style: .default) { _ in completionHandler(.retry) }) default: alertController.title = LocalizedStrings.Onboarding.failedToLogonTitle alertController.message = error.localizedDescription alertController.addAction(UIAlertAction(title: LocalizedStrings.Onboarding.retryTitle, style: .default) { _ in completionHandler(.retry) }) } alertController.addAction(UIAlertAction(title: LocalizedStrings.Onboarding.resetTitle, style: .destructive) { _ in self.resetOnboarding(onboardingID, controller: controller, completionHandler: completionHandler) }) DispatchQueue.main.async { guard let topViewController = ModalUIViewControllerPresenter.topPresentedViewController() else { fatalError("Invalid UI state") } topViewController.present(alertController, animated: true) } }
Add a new function
switchToDuplicateUserWith
to settransientUser
.swiftCopyprivate func switchToDuplicateUserWith(onboardingID: String?, completionHandler: @escaping (OnboardingErrorDisposition) -> Void) { if let id = onboardingID { let user = UserManager().get(forKey: UUID(uuidString: id)!) user?.onboardingStatus = .restoreInProgress UserManager.transientUser = user } else { UserManager.transientUser = nil } completionHandler(.retry) }
Log in to complete tutorial - Step 11
In the menu bar, click Product → Build.
Any errors you see will be cleared after building the project.
Select a suitable simulator/device and run your project.
Log in to complete tutorial - Step 12
Click Start.
Select Default Identity Provider.
Enter username and password, and click Log On.
Click Allow Data.
Click Allow Usage.
Choose a passcode, and click Next.
Biometric authentication is not supported. The biometric screen will not be shown in the onboarding or unlock processes.
No passcode policy is not supported. A default passcode policy will be used if the server has disabled it.
Enter the passcode again, and click Done.
Terminate the app or Send the app to background.
In the sign in screen, click Switch or Add User.
Click
to add a new user.Ensure all users being onboarded have access to the BTP account. You can configure this in the users section under security on your SAP BTP sub-account.
Follow the onboarding flow for the second user.
Terminate the app or Send the app to background.
In the sign in screen, click Switch or Add User.
Select a different user.
Enter the selected user’s passcode.
Which of the following security feature is permitted for multi-user support:
Log in to complete tutorial - Step 13
Sign into User A’s account.
Turn off your network.
If you are running a simulator, turn off your parent system’s WiFi.
Update an entry.
Turn on your network.
Sign in using User B’s account.
Verify the change done by user A.
In this step you’ve seen how the changes done by User A are not lost even when they were done in the absence of network.
Congratulations on completing the tutorial!
Log in to complete tutorial
- Real world use case
- Enable multi-user mode in mobile services cockpit
- Configure trust
- Configure app parameters
- Modify AppDelegate for multi-user onboarding flow
- Configure multi-user callbacks
- Modify onboarding and restore steps
- Configure OData controller for offline scenarios
- Handle offline OData sync failure
- Multi-user error handling
- Build and run the application
- Onboard multiple users
- Try offline scenarios