Skip to content

iOS Session and App Lifecycle Management

YuliyaArtsiomchykScope edited this page Sep 30, 2026 · 3 revisions

iOS Session and App Lifecycle Management

Companion to the iOS SmartDrive framework and iOS SDApiManager Framework pages. Last updated: 13 Jul, 2026.

This page explains how to keep a user logged in across screen-off, backgrounding, and app termination, and how to keep trip recording armed without forcing the user to log in again. If your users are being sent back to the login screen after locking the phone or reopening the app, this page is for you.

Important

The SDK already persists the session for you. SDApiManager stores the auth token internally after login, and SDApiManager.isAuthorized() returns true on the next launch without logging in again. You do not need to store tokens yourself, and you must not clear them on launch. A forced re-login after a screen-off is almost always caused by the app doing something explicit — iOS alone does not require it.

Table of contents

1. The one principle

Three rules cover almost every case:

  1. At launch, check the session — don't re-create it. If isAuthorized() is true, skip the login screen and go straight into the app.
  2. Arm the SDK from the app launch entry point (didFinishLaunchingWithOptions), guarded by isAuthorized(), so recording re-arms itself after a real termination — with no login.
  3. Call logout() in exactly one place: when the user taps Logout (and, if you wish, immediately before a fresh login to clear a stale session). Never on launch, foreground, or background.

If those three hold, screen-off and app-kill both "just work".

2. Two managers: session vs recording

Concern Manager Key calls
Session / auth (who is logged in) SDApiManager isAuthorized(), login(...), logout()
Trip recording (arming / stopping) SCPSmartDriveManager see SmartDrive framework §2–3

Session persistence is handled by SDApiManager. Arming recording is handled by SCPSmartDriveManager. The rest of this page ties the two together in the correct order.

3. Screen-off is not app-kill

Read this first — it is the single most common misunderstanding.

  • Screen off (power button): the app is suspended, then resumes via applicationDidBecomeActive. There is no relaunch and no login — nothing session-related should happen.
  • Terminated (swiped away, memory pressure, long background, or a background-location relaunch): the next open runs didFinishLaunchingWithOptions again. Because the session was persisted, isAuthorized() is still true, so the app re-arms recording and skips the login screen.

Warning

If the app forces a login after a mere screen-off, the app is doing something explicit — almost certainly one of the mistakes in §5. iOS does not require a re-login on resume.

4. Lifecycle contract

What to do at each lifecycle event:

Event Method Do Do NOT
App launch (every start, incl. relaunch after kill) didFinishLaunchingWithOptions Configure API, then arm the SDK guarded by isAuthorized() Re-create or reset the session
Deciding login vs skip login screen viewDidLoad Check the existing session first; skip login if authorized Always show the login form
After successful login login success callback login → fetch account → arm the SDK —
Return to foreground (screen back on) applicationDidBecomeActive Refresh only, guarded by isAuthorized() Log out or re-login
Go to background (screen off) applicationDidEnterBackground Nothing session-related (e.g. schedule background fetch) Log out or tear down the session
After termination next didFinishLaunchingWithOptions Same launch path — session is reused automatically Add anything extra
User taps Logout Logout action only SDApiManager.logout() Call logout() anywhere else

5. Step-by-step implementation

Step 1. App launch

didFinishLaunchingWithOptions is the only method guaranteed to run on every process start — including when iOS relaunches a terminated app in the background. Configure the API and arm the SDK here.

// AppDelegate.swift
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
    UIApplication.shared.isIdleTimerDisabled = true
    UIDevice.current.isBatteryMonitoringEnabled = true

    configureAPI()                 // establishes the base URL (once per launch)
    registerNotifications()
    registerBackgroundFetchHandler()

    // Arms SmartDrive using the PERSISTED session.
    // Self-guards on isAuthorized(), so it is a safe no-op before first login.
    initializeFeatures { error in
        if let error = error {
            logEvent(.AppStart, description: "initializeFeatures error: \(error)")
        }
    }
    return true
}

The arming step guards itself on the persisted session — this is what makes it safe to call on every launch:

// AppDelegate.swift
func initializeFeatures(completion: @escaping (String?) -> Void) {
        // 1. Check authorization
        guard SDApiManager.isAuthorized() else {
            completion("User is not authorized")
            return
        }
        
        // 2. Create a group to track asynchronous operations
        let dispatchGroup = DispatchGroup()
        var configurationError: String?
        
        // 3. Managers initialization (synchronously)
        _ = TripManager.shared
        
        // 4+5. SmartDrive configuration + TripWrapper setup via the ScopeSDK facade
        wireScopeSDKCallbacks()
        ScopeSDK.shared.start(configuration: .app, host: AppScopeHost())
        ScopeSDK.shared.authenticated()
        
        if appType == .beacon {
            _ = BLEFrameworkProxy.shared
            configureBLEFramework()
            _ = SCPBeaconManager.shared
            _ = SBeaconManager.shared
            SBeaconManager.shared.resetScan()
            SBeaconManager.shared.scanForBeacons()
        }
        
        // 6. Wellness Setup (asynchronously)
        dispatchGroup.enter()
        SDApiManager.getMobileSettings { settings in
            defer { dispatchGroup.leave() }
            
            guard let wellnessLicenseKey = settings.wellnessLicenseKey else {
                configurationError = configurationError ?? "Wellness license key is missing"
                return
            }
            WellnessHealth.shared.configure(licenseKey: wellnessLicenseKey)
        }
        
        // 7. Completion (when all operations are completed)
        dispatchGroup.notify(queue: .main) {
            if let error = configurationError {
                logEvent(.AppStart, description: "Configuration completed with error: \(error)")
            } else {
                logEvent(.AppStart, description: "All managers initialized successfully")
            }
            completion(configurationError)
        }
    }

Tip

After the app is killed and iOS relaunches it, this method runs again, sees the stored session, and re-arms recording with no login screen. If the app instead rebuilds a fresh session here, everything is lost.

Step 2. Decide login vs skip: do NOT force login

The login screen must first ask "is there already a valid session?" and, if so, skip straight through.

// LoginViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    setupUI()
    bindViewModel()
    checkExistingSession()      // ← decides whether login is even needed
}

private func checkExistingSession() {
    if viewModel.isAuthenticated() {     // SDApiManager.isAuthorized()
        viewModel.fetchAccountDetails()  // already logged in — no credentials needed
    }
    // else: show the login form
}
// LoginViewModel.swift
func isAuthenticated() -> Bool {
    return SDApiManager.isAuthorized()   // persisted across launches
}

Warning

This is the step most likely to be missing. If the app always shows the login form (or always calls login/logout) on launch, the user is forced to re-authenticate even though a valid session already exists.

Step 3. After a successful login

Order matters: login → fetch account → arm the SDK → main screen.

// LoginViewController.swift — bindViewModel()
viewModel.onLoginSuccess = { [weak self] username in
    DispatchQueue.main.async { self?.handleLoginSuccess(username: username) }
}
viewModel.onAccountFetchSuccess = { [weak self] in
    DispatchQueue.main.async { self?.navigateToMainScreen() }
}
// LoginViewController.swift
private func handleLoginSuccess(username: String) {
    viewModel.fetchAccountDetails()          // loads account + vehicle + base URL
}

private func navigateToMainScreen() {
    AppDelegate.shared.initializeFeatures { error in   // arm SDK after first login
        // ... present the main screen
    }
}

Note

Only after fetchAccountDetails() do the USER ID, vehicle, and base URL populate. Arming the SDK before this runs is why authentication/integration data can show up empty in logs.

Step 4. Returning to the foreground

A suspended app resuming does not re-run launch — it only fires applicationDidBecomeActive. Refresh; never re-login.

// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
    onDidBecomeActive()
}

func onDidBecomeActive() {
    guard SDApiManager.isAuthorized() else { return }  // still logged in? continue
    refreshFeatures()                                  // refresh only — no logout, no re-login
}

Step 5. Going to background or screen off

Turning the screen off suspends the app; it does not terminate it and does not end the session. Do nothing destructive here.

// AppDelegate.swift
func applicationDidEnterBackground(_ application: UIApplication) {
    onDidEnterBackground()   // e.g. schedule background fetch — nothing session-related
}

Step 6. After the app is actually terminated

When iOS terminates the app (memory pressure, or a background-location relaunch), the next start runs Step 1 again. Because the session was persisted, isAuthorized() is still true, so the SDK re-arms and the login screen is skipped. There is nothing extra to implement — it is the same launch path, which is exactly why Step 1 must be correct.

Step 7. The only place to log out

// LoginViewModel.swift — login()
SDApiManager.logout()   // clears any old session, ONLY as part of an explicit login
SDApiManager.login(withUsername: username, password: password) { ... }

SDApiManager.logout() should be called in exactly one place: when the user taps Logout (and, as here, immediately before a fresh login to clear a stale session). It must never be called on launch, on foreground, or on background.

6. Common mistakes that force a re-login

  • ❌ Calling SDApiManager.logout() on launch or foreground (wipes the stored session).
  • ❌ Re-initializing / resetting the API session on every launch instead of reading isAuthorized().
  • ❌ Always showing the login screen instead of calling the "check existing session" step first.
  • ❌ Arming the SDK only from the login screen, so a background relaunch (no UI) never re-arms.
  • ✅ Correct: persist nothing manually, check isAuthorized() at launch, arm the SDK from didFinishLaunchingWithOptions, and only logout() on explicit user action.

7. How to verify

  1. Log in normally and confirm a trip records.
  2. Press the power button to turn the screen off, wait, turn it back on → the app should resume without a login screen.
  3. Swipe the app away (force-quit), reopen → the login screen should be skipped because the session is still valid.
  4. Drive with the app killed → a trip should still be recorded (background relaunch re-arms via Step 1).
  5. Tap Logout → the next launch should show the login screen.

If step 2 or 3 shows a login screen, review §5 Step 2 and the §6 checklist — the app is clearing or ignoring the persisted session.

8. Reference implementation

The SmartDrive demo app implements this exact pattern. Its README has a file-by-file map (AppDelegate.swift, LoginViewController.swift, LoginViewModel.swift) showing where each step above lives, so you can compare it directly against your own app.

Clone this wiki locally