Skip to main content

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

  1. Never use plain SharedPreferences/UserDefaults for sensitive data
  2. Use Keychain (iOS), Keystore (Android), or FlutterSecureStorage (Flutter)
  3. Encrypt data at rest when possible
  4. Clear sensitive data on logout
  5. 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:

  1. Use preferences for non-sensitive app settings
  2. Use secure storage for tokens and credentials
  3. Implement cache-first strategies for better UX
  4. Set appropriate cache expiration policies
  5. Handle cache invalidation on logout
  6. Never store sensitive data in plain preferences
  7. Use database storage for relational or large datasets
Data TypeiOSAndroidFlutter
PreferencesUserDefaultsDataStoreSharedPreferences
SecureKeychainEncryptedSharedPreferencesFlutterSecureStorage
DatabaseSwiftData/CoreDataRoomHive/SQLite