Preferences & Secure Storage
Preferences (Non-Sensitive Data)
Use for storing non-sensitive application preferences like theme, onboarding status, and user settings.
iOS - UserDefaults
// App/Core/Storage/Preferences.swift
enum PrefKeys {
static let onboardingCompleted = "onboarding_completed"
static let selectedTheme = "selected_theme"
static let notificationsEnabled = "notifications_enabled"
}
protocol Preferences {
var onboardingCompleted: Bool { get set }
var selectedTheme: String { get set }
var notificationsEnabled: Bool { get set }
}
final class UserDefaultsPreferences: Preferences {
private let defaults = UserDefaults.standard
var onboardingCompleted: Bool {
get { defaults.bool(forKey: PrefKeys.onboardingCompleted) }
set { defaults.set(newValue, forKey: PrefKeys.onboardingCompleted) }
}
var selectedTheme: String {
get { defaults.string(forKey: PrefKeys.selectedTheme) ?? "system" }
set { defaults.set(newValue, forKey: PrefKeys.selectedTheme) }
}
var notificationsEnabled: Bool {
get { defaults.bool(forKey: PrefKeys.notificationsEnabled) }
set { defaults.set(newValue, forKey: PrefKeys.notificationsEnabled) }
}
}
Android - DataStore (Preferred)
// app/core/storage/PreferencesManager.kt
class PreferencesManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.dataStore
val onboardingCompleted: Flow<Boolean> = dataStore.data
.map { it[ONBOARDING_COMPLETED] ?: false }
val selectedTheme: Flow<String> = dataStore.data
.map { it[SELECTED_THEME] ?: "system" }
val notificationsEnabled: Flow<Boolean> = dataStore.data
.map { it[NOTIFICATIONS_ENABLED] ?: true }
suspend fun setOnboardingCompleted(completed: Boolean) {
dataStore.edit { it[ONBOARDING_COMPLETED] = completed }
}
suspend fun setSelectedTheme(theme: String) {
dataStore.edit { it[SELECTED_THEME] = theme }
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.edit { it[NOTIFICATIONS_ENABLED] = enabled }
}
companion object {
private val Context.dataStore by preferencesDataStore("settings")
private val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
private val SELECTED_THEME = stringPreferencesKey("selected_theme")
private val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
}
}
Flutter - SharedPreferences
// lib/core/storage/preferences_manager.dart
class PreferencesManager {
final SharedPreferences _prefs;
PreferencesManager(this._prefs);
static const _onboardingCompletedKey = 'onboarding_completed';
static const _selectedThemeKey = 'selected_theme';
static const _notificationsEnabledKey = 'notifications_enabled';
bool get onboardingCompleted =>
_prefs.getBool(_onboardingCompletedKey) ?? false;
String get selectedTheme =>
_prefs.getString(_selectedThemeKey) ?? 'system';
bool get notificationsEnabled =>
_prefs.getBool(_notificationsEnabledKey) ?? true;
Future<void> setOnboardingCompleted(bool completed) =>
_prefs.setBool(_onboardingCompletedKey, completed);
Future<void> setSelectedTheme(String theme) =>
_prefs.setString(_selectedThemeKey, theme);
Future<void> setNotificationsEnabled(bool enabled) =>
_prefs.setBool(_notificationsEnabledKey, enabled);
}
Secure Storage (Sensitive Data)
Use for storing sensitive data like tokens, API keys, and user credentials.
See Authentication & Token Management for token storage implementations.
Rules for Secure Storage
- Never use plain SharedPreferences/UserDefaults for sensitive data
- Use Keychain (iOS), Keystore (Android), or FlutterSecureStorage (Flutter)
- Encrypt data at rest when possible
- Clear sensitive data on logout
- Never log sensitive data
Database Storage
iOS - SwiftData
// App/Features/Feed/Data/Local/CachedPost.swift
import SwiftData
@Model
final class CachedPost {
@Attribute(.unique) var id: String
var title: String
var content: String
var authorId: String
var createdAt: Date
var cachedAt: Date
init(id: String, title: String, content: String, authorId: String, createdAt: Date) {
self.id = id
self.title = title
self.content = content
self.authorId = authorId
self.createdAt = createdAt
self.cachedAt = Date()
}
}
// Repository with SwiftData
final class FeedRepositoryImpl: FeedRepository {
private let modelContext: ModelContext
private let remoteDataSource: FeedRemoteDataSource
func getPosts() async throws -> [Post] {
// Return cached data immediately
let cached = try modelContext.fetch(FetchDescriptor<CachedPost>())
// Refresh asynchronously (ModelContext is not thread-safe; keep updates on the same actor)
Task { @MainActor in
try? await refreshPosts()
}
return cached.map { $0.toDomain() }
}
private func refreshPosts() async throws {
let posts = try await remoteDataSource.fetchPosts()
// Replace cache to avoid duplicates / unique constraint failures
let existing = try modelContext.fetch(FetchDescriptor<CachedPost>())
for item in existing {
modelContext.delete(item)
}
// Update cache
for post in posts {
let cached = CachedPost(
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt
)
modelContext.insert(cached)
}
try modelContext.save()
}
}
Android - Room
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// app/features/feed/data/local/room/PostEntity.kt
@Entity(tableName = "posts")
data class PostEntity(
@PrimaryKey val id: String,
val title: String,
val content: String,
val authorId: String,
val createdAt: Long,
val cachedAt: Long = System.currentTimeMillis()
)
// app/features/feed/data/local/room/PostDao.kt
@Dao
interface PostDao {
@Query("SELECT * FROM posts ORDER BY createdAt DESC")
fun getAllPosts(): Flow<List<PostEntity>>
@Query("SELECT * FROM posts WHERE id = :id")
suspend fun getPost(id: String): PostEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPosts(posts: List<PostEntity>)
@Query("DELETE FROM posts")
suspend fun clearAll()
@Query("DELETE FROM posts WHERE cachedAt < :timestamp")
suspend fun deleteOlderThan(timestamp: Long)
}
// app/features/feed/data/local/room/AppDatabase.kt
@Database(
entities = [PostEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun postDao(): PostDao
}
// Repository with Room
class FeedRepositoryImpl @Inject constructor(
private val postDao: PostDao,
private val remoteDataSource: FeedRemoteDataSource
) : FeedRepository {
override fun getPosts(): Flow<List<Post>> {
// Return cached data as Flow
return postDao.getAllPosts()
.map { entities -> entities.map { it.toDomain() } }
.onStart {
// Refresh in background
withContext(Dispatchers.IO) { refreshPosts() }
}
}
private suspend fun refreshPosts() {
try {
val posts = remoteDataSource.fetchPosts()
val entities = posts.map { it.toEntity() }
postDao.insertPosts(entities)
} catch (e: Exception) {
// Log error, but don't fail - cached data is still available
Timber.e(e, "Failed to refresh posts")
}
}
}
Flutter - Hive & SQLite
Hive (Simple Key-Value)
// lib/features/feed/data/sources/local/hive/cached_post.dart
(typeId: 0)
class CachedPost extends HiveObject {
(0)
final String id;
(1)
final String title;
(2)
final String content;
(3)
final String authorId;
(4)
final DateTime createdAt;
(5)
final DateTime cachedAt;
CachedPost({
required this.id,
required this.title,
required this.content,
required this.authorId,
required this.createdAt,
required this.cachedAt,
});
}
// Repository with Hive
class FeedRepositoryImpl implements FeedRepository {
final Box<CachedPost> _box;
final FeedRemoteDataSource _remoteDataSource;
FeedRepositoryImpl(this._box, this._remoteDataSource);
Future<List<Post>> getPosts() async {
// Return cached data
final cached = _box.values.map((e) => e.toDomain()).toList();
// Refresh in background
unawaited(_refreshPosts());
return cached;
}
Future<void> _refreshPosts() async {
try {
final posts = await _remoteDataSource.fetchPosts();
await _box.clear();
for (final post in posts) {
await _box.put(
post.id,
CachedPost(
id: post.id,
title: post.title,
content: post.content,
authorId: post.authorId,
createdAt: post.createdAt,
cachedAt: DateTime.now(),
),
);
}
} catch (e) {
// Log error, cached data is still available
}
}
}
SQLite (Relational Data)
// lib/features/feed/data/sources/local/sqlite/database_helper.dart
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._();
static Database? _database;
DatabaseHelper._();
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final path = join(await getDatabasesPath(), 'app.db');
return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
cached_at INTEGER NOT NULL
)
''');
},
);
}
Future<List<Map<String, dynamic>>> getPosts() async {
final db = await database;
return db.query('posts', orderBy: 'created_at DESC');
}
Future<void> insertPosts(List<Map<String, dynamic>> posts) async {
final db = await database;
final batch = db.batch();
for (final post in posts) {
batch.insert('posts', post, conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
Future<void> clearAll() async {
final db = await database;
await db.delete('posts');
}
}
Cache Invalidation
Implement cache expiration strategies.
// iOS
func shouldRefreshCache() -> Bool {
guard let timestamp = cacheTimestamp else { return true }
let age = Date().timeIntervalSince(timestamp)
return age > 300 // 5 minutes
}
// Android
suspend fun clearExpiredCache() {
val fiveMinutesAgo = System.currentTimeMillis() - (5 * 60 * 1000)
postDao.deleteOlderThan(fiveMinutesAgo)
}
// Flutter
Future<void> clearExpiredCache() async {
final fiveMinutesAgo = DateTime.now().subtract(const Duration(minutes: 5));
final expiredKeys = _box.values
.where((post) => post.cachedAt.isBefore(fiveMinutesAgo))
.map((post) => post.id)
.toList();
await _box.deleteAll(expiredKeys);
}
Summary
Local Storage Best Practices:
- Use preferences for non-sensitive app settings
- Use secure storage for tokens and credentials
- Implement cache-first strategies for better UX
- Set appropriate cache expiration policies
- Handle cache invalidation on logout
- Never store sensitive data in plain preferences
- Use database storage for relational or large datasets
| Data Type | iOS | Android | Flutter |
|---|---|---|---|
| Preferences | UserDefaults | DataStore | SharedPreferences |
| Secure | Keychain | EncryptedSharedPreferences | FlutterSecureStorage |
| Database | SwiftData/CoreData | Room | Hive/SQLite |