Initialization
Before using any AFCore feature, the SDK must be initialized with an AFCoreConfig. This page covers configuration, platform-specific initialization, readiness observation, and reconfiguration.
Building AFCoreConfig
AFCoreConfig is built using the builder pattern. It requires three mandatory fields and two optional logging fields:
| Field | Required | Description |
|---|---|---|
baseUrl | Yes | The API base URL (must be HTTPS) |
clientId | Yes | OAuth client identifier |
clientSecret | Yes | OAuth client secret |
enableLogging | No | Enable/disable SDK logging (default: false) |
logLevel | No | Log verbosity level (default: OFF) |
logWriter | No | Custom log writer for routing SDK logs to your logging framework |
- Android (Kotlin)
- iOS (Swift)
val config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(BuildConfig.DEBUG)
.logLevel(AFLogLevel.DEBUG)
.build()
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(true)
.logLevel(.debug)
.build()
Log Levels
The AFLogLevel enum controls which log messages are emitted:
| Level | Description |
|---|---|
VERBOSE | All messages, including detailed internal state |
DEBUG | Debug messages and above |
INFO | Informational messages and above |
WARN | Warnings and errors only |
ERROR | Errors only |
ASSERT | Critical assertions only |
OFF | No logging (default) |
Custom Log Writer
You can route SDK logs to your own logging framework by providing a custom AFLogWriter:
- Android (Kotlin)
- iOS (Swift)
val config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(true)
.logLevel(AFLogLevel.DEBUG)
.logWriter(object : AFLogWriter {
override fun log(level: AFLogLevel, tag: String, message: String, throwable: Throwable?) {
Timber.tag(tag).d(throwable, message) // Route to Timber
}
})
.build()
class MyLogWriter: AFLogWriter {
func log(level: AFLogLevel, tag: String, message: String, throwable: KotlinThrowable?) {
os_log("%{public}@: %{public}@", tag, message)
}
}
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(true)
.logLevel(.debug)
.logWriter(MyLogWriter())
.build()
Calling initialize()
AFCore.initialize(config) must be the first SDK method called. It performs the following steps:
- Configures the logging pipeline.
- Creates or opens encrypted storage (EncryptedSharedPreferences on Android, Keychain on iOS/watchOS).
- Persists client credentials in encrypted storage.
- Builds the Ktor
HttpClientwith the platform engine and plugin pipeline. - Persists the config in
AFBootConfigStorefor boot-time recovery (Android/iOS only). - If a previous session exists, launches background auto-restore (Android/iOS only).
Return Value
initialize() returns a Boolean:
| Return | Meaning |
|---|---|
true | Initialization succeeded |
false | Initialization failed (error is logged) |
If initialization fails, the SDK remains uninitialized. Any subsequent calls to feature accessors will throw IllegalStateException.
Platform-Specific Setup
Android
Call AFCore.initialize() in your Application.onCreate(). This runs before any Activity, Service, or BroadcastReceiver, ensuring the SDK is ready for all components.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(BuildConfig.DEBUG)
.logLevel(if (BuildConfig.DEBUG) AFLogLevel.DEBUG else AFLogLevel.OFF)
.build()
val success = AFCore.initialize(config)
if (!success) {
Log.e("MyApp", "AFCore initialization failed")
}
}
}
AFCore uses Android's App Startup library (AFContextProvider) to obtain the Application context automatically. You do not need to pass a Context to initialize().
AFCore's library manifest declares every permission its features may use (location, background location, activity recognition, camera, notifications, Health Connect). Because Android merges library manifests into your app, your app inherits all of them. If you do not use a particular feature, strip the unwanted permission in your app's AndroidManifest.xml with tools:node="remove":
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove" />
iOS (AppDelegate)
Call AFCore.shared.initialize() in application(_:didFinishLaunchingWithOptions:):
import UIKit
import AFCore
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(true)
.logLevel(.debug)
.build()
let success = AFCore.shared.initialize(config: config)
if !success {
print("AFCore initialization failed")
}
return true
}
}
iOS (SwiftUI App)
For SwiftUI lifecycle apps, initialize in the App struct's init():
import SwiftUI
import AFCore
@main
struct MyApp: App {
init() {
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.enableLogging(true)
.logLevel(.debug)
.build()
let _ = AFCore.shared.initialize(config: config)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
watchOS
watchOS initialization is synchronous -- there is no auto-restore, so isReady is set to true immediately after initialize() completes:
import WatchKit
import AFCore
class ExtensionDelegate: NSObject, WKApplicationDelegate {
func applicationDidFinishLaunching() {
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.build()
let _ = AFCore.shared.initialize(config: config)
// isReady is already true here -- no auto-restore on watchOS
}
}
Background Tasks Setup (iOS)
AFCore queues fire-and-forget submissions (geofence events, automatic visits, self-reports, barcodes, diagnostics, SpotIt feedback) into an offline outbox that delivers later when connectivity returns. To let that outbox drain while your app is suspended in the background, opt into iOS background tasks. Without this setup the outbox still flushes on enqueue, on connectivity restore, and on the next AFCore.initialize -- but not opportunistically while suspended.
Three steps are required in the host app:
- Info.plist -- add the SDK's task identifier under
BGTaskSchedulerPermittedIdentifiers:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.advantahealth.afcore.outbox-flush</string>
</array>
-
Capabilities -- in your target's Signing & Capabilities, add Background Modes and enable Background fetch (background app refresh / processing).
-
AppDelegate /
@main-- callAFBackgroundTasks.shared.registerHandler()beforeapplication(_:didFinishLaunchingWithOptions:)returns. iOS rejects handler registrations attempted after launch completes. The call is idempotent.AFCore.initializeauto-submits the first refresh request, and the handler self-perpetuates by scheduling the next one before each flush.
import UIKit
import AFCore
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Register BEFORE didFinishLaunchingWithOptions returns.
AFBackgroundTasks.shared.registerHandler()
let config = AFCoreConfig.builder()
.baseUrl("https://api.example.com")
.clientId("YOUR_CLIENT_ID")
.clientSecret("YOUR_CLIENT_SECRET")
.build()
let _ = AFCore.shared.initialize(config: config)
return true
}
}
On Android the outbox flushes on a periodic WorkManager job with no consumer setup required -- this background-task wiring is iOS-only.
Observing Readiness
On Android and iOS, initialize() may launch a background coroutine to restore geofences and SmartWalking auto-sync from a previous session. During this time, isReady remains false. It becomes true once all auto-restore work has completed (or immediately if there is no session to restore).
Best practice: Defer navigation or data-fetching UI until isReady is true to avoid races with auto-restore coroutines.
- Android (Kotlin)
- iOS (Swift)
// In an Activity or Fragment
lifecycleScope.launch {
AFCore.isReady.first { it }
// SDK is fully initialized -- safe to proceed
navigateToHome()
}
// Using async/await
Task {
for await ready in AFCore.shared.isReady where ready {
navigateToHome()
break
}
}
What Auto-Restore Does
When initialize() detects an existing session (non-blank access token), it:
- Fetches entitlements -- Calls
programs().getAvailablePrograms()to validate the session and check feature entitlements. This also triggers a token refresh if the token is near expiry. - Restores geofencing -- If geofencing was previously active (
preferences.geofencingStarted == true), restores geofence monitoring using the persisted geofence state. - Restores SmartWalking -- If auto-sync was enabled (
preferences.smartWalkingAutoSyncEnabled == true) and the member still has the SmartWalking entitlement, re-enables auto-sync and triggers an immediate step sync.
If any of these steps fail, the failure is logged but does not prevent isReady from becoming true. Auto-restore failures are non-fatal.
Idempotency
initialize() is idempotent when called with the same configuration:
| Scenario | Behavior |
|---|---|
| Same config, already initialized | No-op, returns true |
| Different config, already initialized | Logs a warning, returns without re-initializing |
| Not yet initialized | Performs full initialization |
To change the configuration after initialization, use reconfigure() instead of calling initialize() again.
Reconfiguration
AFCore.reconfigure(config) tears down the current SDK state and re-initializes with a new configuration. This is useful for switching environments at runtime (e.g., staging to production) or swapping client credentials.
- Android (Kotlin)
- iOS (Swift)
// Switch to staging environment
val stagingConfig = AFCoreConfig.builder()
.baseUrl("https://staging.example.com")
.clientId("STAGING_CLIENT_ID")
.clientSecret("STAGING_CLIENT_SECRET")
.enableLogging(true)
.logLevel(AFLogLevel.VERBOSE)
.build()
val success = AFCore.reconfigure(stagingConfig)
let stagingConfig = AFCoreConfig.builder()
.baseUrl("https://staging.example.com")
.clientId("STAGING_CLIENT_ID")
.clientSecret("STAGING_CLIENT_SECRET")
.enableLogging(true)
.logLevel(.verbose)
.build()
let success = AFCore.shared.reconfigure(config: stagingConfig)
What reconfigure() Does
- Resets all state: Clears cached module instances, emits
SessionState.LOGGED_OUT, setsisReadytofalse. - Re-initializes: Calls
initialize()with the new config.
reconfigure() clears the current session. The user will need to re-authenticate after reconfiguration. Do not call this during normal operation -- it is intended for environment switching or credential rotation.
Thread Safety
| Platform | Mechanism |
|---|---|
| Android | @Synchronized on initialize(), reconfigure(), and reset(). Prevents concurrent re-initialization from multiple threads (e.g., main thread + WorkManager callback). |
| iOS | @Volatile fields ensure cross-thread visibility. Kotlin/Native's concurrency model provides additional safety. |
| watchOS | Same as iOS, but simpler since there is no background work. |
Error Handling
If initialize() returns false, check the SDK logs for the specific error. Common failure causes:
| Cause | Platform | Resolution |
|---|---|---|
| Keychain access denied | iOS | Ensure the app has the Keychain Sharing entitlement if needed |
| Android Keystore unavailable | Android | Usually a device-level issue; the SDK attempts to recreate corrupted preferences |
| Invalid base URL | All | Ensure the URL starts with https:// and contains a valid host |
| Empty client credentials | All | Ensure clientId and clientSecret are non-empty |