Frequently Asked Questions
Which platforms does AFCore support?
AFCore supports three platforms:
| Platform | Minimum Version | Notes |
|---|---|---|
| Android | API 24 (Android 7.0) | Full feature set |
| iOS | 16.0 | Full feature set |
| watchOS | 9.0 | Limited -- no geofencing, no barcode, no proximity, no background sync |
AFCore is built with Kotlin Multiplatform (KMP), sharing business logic across all platforms while using native APIs for storage, networking, and background work.
Is AFCore thread-safe?
Yes. All public APIs are safe to call from any thread or coroutine context. Internally, the SDK uses multiple concurrency mechanisms:
| Mechanism | Used For |
|---|---|
@Volatile | Lazy singleton references for feature modules |
@Synchronized | Initialization on Android (prevents concurrent initialize() calls) |
Mutex | Cache read/write, token refresh operations |
CompletableDeferred | Request coalescing for concurrent identical GET requests |
SupervisorJob | Internal AFCoreScope isolates background failures |
You do not need to add your own synchronization around SDK calls.
Do I need to manage tokens?
No. Token management is fully automatic:
- The
DefaultTokenManagermonitors token expiration with a 5-minute threshold (refreshes 5 minutes before expiry) - On refresh failure, it retries with exponential backoff (1 second, 2 seconds, 4 seconds ... up to 30 seconds)
- All token operations are mutex-protected to prevent concurrent refresh races
- If the token cannot be refreshed, the SDK emits
SESSION_EXPIREDon thesessionStateflow
Your only responsibility is to observe sessionState and prompt re-login when the session expires:
- Android (Kotlin)
- iOS (Swift)
viewModelScope.launch {
AFCore.authentication().sessionState.collect { state ->
if (state == SessionState.SESSION_EXPIRED) {
navigateToLogin()
}
}
}
Task {
for await state in AFCore.authentication().sessionState {
if state == .sessionExpired {
navigateToLogin()
}
}
}
Can I use AFCore from Java?
Yes, but Kotlin is recommended. Since AFCore's API uses Kotlin suspend functions, you need coroutine wrappers to call them from Java:
// Java: Use kotlinx.coroutines to call suspend functions
BuildersKt.launch(
GlobalScope.INSTANCE,
Dispatchers.getMain(),
CoroutineStart.DEFAULT,
(scope, continuation) -> {
AFCore.INSTANCE.profile().get(continuation);
return Unit.INSTANCE;
}
);
Alternatively, create Kotlin wrapper functions that expose callbacks:
// Kotlin wrapper for Java consumers
object AFCoreJavaHelper {
@JvmStatic
fun getProfile(callback: (AFProfile?, Exception?) -> Unit) {
CoroutineScope(Dispatchers.Main).launch {
try {
val profile = AFCore.profile().get()
callback(profile, null)
} catch (e: Exception) {
callback(null, e)
}
}
}
}
// Java: Call the wrapper
AFCoreJavaHelper.getProfile((profile, error) -> {
if (error != null) {
showError(error.getMessage());
} else {
updateUI(profile);
}
});
Can I use AFCore from Objective-C?
Yes. Kotlin Multiplatform generates Objective-C-compatible headers for iOS. Suspend functions are automatically converted to completion handler-based APIs:
// Objective-C: Suspend functions become completion handler calls
[[AFCore authentication] loginWithUsername:@"user@example.com"
password:@"password"
completionHandler:^(AFLoginResult *result, NSError *error) {
if (error) {
NSLog(@"Login failed: %@", error.localizedDescription);
} else {
NSLog(@"Login succeeded");
}
}];
[[AFCore profile] getWithCompletionHandler:^(AFProfile *profile, NSError *error) {
if (error) {
NSLog(@"Failed to fetch profile: %@", error.localizedDescription);
} else {
NSLog(@"Name: %@", profile.name);
}
}];
How do I handle session expiration?
Observe the sessionState flow. When the state transitions to SESSION_EXPIRED, prompt the user to log in again.
- Android (Kotlin)
- iOS (Swift)
class MainViewModel : ViewModel() {
init {
viewModelScope.launch {
AFCore.sessionState.collect { state ->
when (state) {
SessionState.LOGGED_IN -> { /* User is logged in */ }
SessionState.SESSION_EXPIRED -> {
// Token refresh failed after retries
// Prompt user to re-authenticate
_navigateToLogin.emit(Unit)
}
SessionState.LOGGED_OUT -> {
// User has not logged in yet or has logged out
_navigateToLogin.emit(Unit)
}
}
}
}
}
}
class MainViewModel: ObservableObject {
@Published var shouldShowLogin = false
init() {
Task {
for await state in AFCore.shared.sessionState {
await MainActor.run {
switch state {
case .loggedIn:
shouldShowLogin = false
case .sessionExpired:
// Token refresh failed — prompt re-login
shouldShowLogin = true
case .loggedOut:
shouldShowLogin = true
default:
break
}
}
}
}
}
}
Does the SDK cache responses?
Yes. AFCore includes an internal AFCoreCachePlugin with the following behavior:
| Property | Value |
|---|---|
| Cached methods | GET only |
| TTL | 15 minutes per entry |
| Max entries | 100 (LRU eviction) |
| Request coalescing | Concurrent identical GETs are merged |
| Mutations | POST, PUT, DELETE always skip the cache |
| Manual bypass | Use skipCache parameter on GET requests |
| Persistence | In-memory only (cleared on process kill) |
You do not need to implement your own caching layer.
How does geofencing survive app kill?
Geofence survival works at two levels:
-
OS level: Both Android and iOS maintain geofence registrations in the OS independently of your app process. When a geofence triggers, the OS wakes your app (or relaunches it if killed).
-
SDK level: AFCore persists its internal geofence state in
AFBootConfigStore. When the app relaunches,AFCore.initialize()reads this state and restores the SDK's bookkeeping to match the OS-level registrations.
This two-layer approach means geofences continue to work even after:
- The user swipes the app away from the task switcher
- The OS kills the app for memory pressure
- The device reboots (Android with
RECEIVE_BOOT_COMPLETED)
No action is required from you beyond calling initialize() in your app entry point.
What is the difference between syncSteps() and submitActivities()?
These two methods serve different use cases:
| Method | Input | Use Case |
|---|---|---|
syncSteps() | None (reads from HealthKit / Health Connect automatically) | Standard step sync -- the SDK handles everything |
submitActivities(healthData) | Pre-fetched HealthData object | Advanced use -- you provide your own health data |
For most apps, use syncSteps() or enableAutoSync(). Use submitActivities() only if you are reading health data yourself and need to submit it in a custom format.
- Android (Kotlin)
- iOS (Swift)
// Standard approach: Let the SDK read and submit steps
AFCore.smartWalking().syncSteps()
// Or enable auto-sync and let the SDK handle scheduling
AFCore.smartWalking().enableAutoSync()
// Advanced: Submit your own health data
val healthData = HealthData(
steps = listOf(StepRecord(date = "2026-02-22", count = 8500))
)
AFCore.smartWalking().submitActivities(healthData)
// Standard approach: Let the SDK read and submit steps
try await AFCore.smartWalking().syncSteps()
// Or enable auto-sync and let the SDK handle scheduling
try await AFCore.smartWalking().enableAutoSync()
// Advanced: Submit your own health data
let healthData = HealthData(
steps: [StepRecord(date: "2026-02-22", count: 8500)]
)
try await AFCore.smartWalking().submitActivities(healthData: healthData)
Can I customize the cache TTL?
No. The 15-minute TTL is configured internally in AFCoreCachePlugin and is not currently exposed in the public API. This value was chosen to balance freshness with network efficiency for the typical usage patterns of fitness and wellness data.
If you need data fresher than 15 minutes for a specific call, use the skipCache parameter:
- Android (Kotlin)
- iOS (Swift)
// Force a fresh response, bypassing the cache
val profile = AFCore.profile().get(skipCache = true)
// Force a fresh response, bypassing the cache
let profile = try await AFCore.profile().get(skipCache: true)
What data is encrypted?
AFCore uses platform-native encryption for sensitive data:
| Data | Storage | Encrypted |
|---|---|---|
| Auth tokens (access, refresh) | EncryptedSharedPreferences (Android) / Keychain (iOS) | Yes |
| User profile data | EncryptedSharedPreferences / Keychain | Yes |
| Feature state (AFPreferences) | EncryptedSharedPreferences / Keychain | Yes |
| Boot config (baseUrl, clientId) | Standard SharedPreferences / UserDefaults | No |
| HTTP cache | In-memory only | N/A (not persisted) |
Boot configuration is stored in plain preferences intentionally. It must be readable before the user unlocks the device so the SDK can restore geofences and background tasks on boot.
Auth tokens and user data are always encrypted at rest. The unencrypted boot config contains only non-sensitive configuration values (base URL, client ID) needed for early initialization.
How do I enable logging?
Configure logging in the AFCoreConfig builder. You can set the log level and optionally provide a custom log writer.
- Android (Kotlin)
- iOS (Swift)
AFCore.initialize(
AFCoreConfig.Builder(context = this)
.baseUrl("https://api.example.com")
.clientId("your-client-id")
.clientSecret("your-client-secret")
.enableLogging(true)
.logLevel(AFLogLevel.DEBUG) // VERBOSE, DEBUG, INFO, WARN, ERROR
.build()
)
For custom log output (e.g., writing to a file or crash reporting service):
AFCore.initialize(
AFCoreConfig.Builder(context = this)
.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) {
// Send to Crashlytics, file, or custom logger
Crashlytics.log("[$level] $tag: $message")
}
})
.build()
)
AFCore.initialize(
config: AFCoreConfig.Builder()
.baseUrl(url: "https://api.example.com")
.clientId(id: "your-client-id")
.clientSecret(secret: "your-client-secret")
.enableLogging(enabled: true)
.logLevel(level: .debug) // .verbose, .debug, .info, .warn, .error
.build()
)
For custom log output:
class CrashlyticsLogWriter: AFLogWriter {
func log(level: AFLogLevel, tag: String, message: String) {
Crashlytics.crashlytics().log("[\(level)] \(tag): \(message)")
}
}
AFCore.initialize(
config: AFCoreConfig.Builder()
.baseUrl(url: "https://api.example.com")
.clientId(id: "your-client-id")
.clientSecret(secret: "your-client-secret")
.enableLogging(enabled: true)
.logLevel(level: .debug)
.logWriter(writer: CrashlyticsLogWriter())
.build()
)
Log Levels
| Level | Output |
|---|---|
VERBOSE | Everything, including raw HTTP bodies |
DEBUG | Detailed internal state, cache hits/misses, token refresh events |
INFO | High-level lifecycle events (init, login, sync) |
WARN | Recoverable issues (retry attempts, fallback behavior) |
ERROR | Failures that affect functionality |
Do not ship production builds with VERBOSE or DEBUG logging enabled. These levels include sensitive information such as HTTP request/response bodies and token details. Use WARN or ERROR for production.