-
Notifications
You must be signed in to change notification settings - Fork 0
Hybrid Repositories Architecture
This document explains the architecture of the Warnastrophy application's hybrid repositories,
which enable seamless synchronization between local and remote storage.
Hybrid repositories combine two data sources:
- Local: Persistent storage on the device (DataStore/Room)
- Remote: Cloud storage (Firebase Firestore)
This architecture offers several advantages:
- Offline mode: The application works even without an internet connection
- Automatic synchronization: Data is synchronized as soon as possible
- Performance: Fast reading from the local cache
- Resilience: Automatic failover in case of server unavailability
┌─────────────────────────────────────────────────────────────┐
│ ViewModel │
│ (Business logic) │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Hybrid Repository │
│ ┌────────────────────────────────────────────────────┐ │
│ │ • local ↔ remote synchronization │ │
│ │ • Remote availability status management │ │
│ │ • Fallback strategy │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────┬────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Local Repository │ │ Remote Repository│
│ (Room/ │ │ (Firestore) │
│ DataStore) │ │ │
└──────────────────┘ └──────────────────┘
The application uses three hybrid repositories:
Manages the user's emergency contacts.
Manages the user's health card.
Manages user preferences (dark mode, alerts, etc.).
private fun initializeRepositories(isAuthenticated: Boolean) {
if (isAuthenticated) {
// Hybrid mode : synchronization with Firebase
HealthCardRepositoryProvider.useHybridEncrypted(applicationContext, db, auth)
ContactRepositoryProvider.initHybrid(applicationContext, db)
UserPreferencesRepositoryProvider.initHybrid(applicationContext.userPrefsDataStore, db)
} else {
// Local mode only : no synchronization
HealthCardRepositoryProvider.init(applicationContext)
ContactRepositoryProvider.initLocal(applicationContext)
UserPreferencesRepositoryProvider.initLocal(applicationContext.userPrefsDataStore)
}
}Logic:
- User connected → Hybrid repositories enabled
- User non-connected → Local repositories only
The hybrid repositories follow a Remote-First with Local Fallback strategy:
┌──────────────┐
│ Read Request │
└──────┬───────┘
│
▼
┌─────────────────┐
│ Try Remote Read │
└──────┬──────────┘
│
├─── Success ──────┐
│ ▼
│ ┌──────────────┐
│ │ Sync to Local│
│ └──────┬───────┘
│ │
│ ▼
│ ┌──────────────┐
│ │Return Remote │
│ │ Data │
│ └──────────────┘
│
└─── Failure ─────┐
▼
┌──────────────┐
│ Read from │
│ Local │
└──────┬───────┘
│
▼
┌──────────────┐
│Return Local │
│ Data │
└──────────────┘
The hybrid repositories follow a Local-First with Remote Sync strategy:
┌───────────────┐
│ Write Request │
└───────┬───────┘
│
▼
┌────────────────┐
│ Write to Local │
└───────┬────────┘
│
├─── Success ──────┐
│ ▼
│ ┌─────────────────┐
│ │ Is Remote │
│ │ Available? │
│ └────┬────────────┘
│ │
│ ├─── Yes ──────┐
│ │ ▼
│ │ ┌─────────────────┐
│ │ │ Write to Remote │
│ │ └────┬────────────┘
│ │ │
│ │ ├─── Success ─→ Return Success
│ │ │
│ │ └─── Failure ──┐
│ │ ▼
│ │ ┌────────────────┐
│ │ │ Mark Remote as │
│ │ │ Unavailable │
│ │ └────────────────┘
│ │
│ └─── No ─────→ Return Local Success
│
└─── Failure ──→ Return Failure
- Manages emergency contacts with ID, last name, first name, number, email
- Supports complete CRUD operations
- Two-way synchronization
override suspend fun getAllContacts(userId: String): Result<List<Contact>> {
try {
// 1. Remote read attempt
val remoteResult = remote.getAllContacts(userId)
if (remoteResult.isSuccess) {
val remoteContacts = remoteResult.getOrThrow()
// 2. Synchronization to local
syncRemoteToLocal(userId, remoteContacts)
// 3. Mark remote as available
syncMutex.withLock { isRemoteAvailable = true }
return Result.success(remoteContacts)
}
} catch (e: Exception) {
// 4. If unsuccessful, mark remote as unavailable
syncMutex.withLock { isRemoteAvailable = false }
Log.e(TAG, "Remote getAllContacts failed: ${e.localizedMessage}")
}
// 5. Fallback to local
return local.getAllContacts(userId)
}override suspend fun addContact(userId: String, contact: Contact): Result<Unit> {
return updateBothRepositories(
localOperation = { local.addContact(userId, contact) },
remoteOperation = { remote.addContact(userId, contact) }
)
}
private suspend fun updateBothRepositories(
localOperation: suspend () -> Result<Unit>,
remoteOperation: suspend () -> Result<Unit>
): Result<Unit> {
// 1. Local writing first
val localResult = localOperation()
if (localResult.isFailure) {
return localResult
}
// 2. Check if remote is available
val shouldUpdateRemote = syncMutex.withLock { isRemoteAvailable }
if (!shouldUpdateRemote) return localResult
// 3. Attempt remote writing
val remoteResult = remoteOperation()
if (remoteResult.isFailure) {
syncMutex.withLock { isRemoteAvailable = false }
Log.e(TAG, "Remote update failed, marking remote as unavailable")
return remoteResult
}
return localResult
}private suspend fun syncRemoteToLocal(userId: String, remoteContacts: List<Contact>) {
try {
// 1. Retrieve existing local contacts
val localContacts = local.getAllContacts(userId).getOrNull().orEmpty()
val localIds = localContacts.map { it.id }.toSet()
// 2. Add only new contacts
for (contact in remoteContacts) {
if (contact.id !in localIds) {
local.addContact(userId, contact)
}
}
} catch (e: Exception) {
Log.e(TAG, "Sync to local failed: ${e.localizedMessage}")
}
}- Manages a single health card per user
- Use
Flowfor reactive observation - Cache-first mode support
override fun observeMyHealthCard(): Flow<HealthCard?> = flow {
emitAll(
local.observeMyHealthCard()
// Synchronization at startup
.onStart { syncRemoteToLocal() }
// Fallback to remote in case of error
.catch { error ->
Log.e(TAG, "Local observation failed")
emitAll(
remote.observeMyHealthCard().catch {
Log.e(TAG, "Remote observation failed")
throw error
}
)
}
)
}Observation flow :
- Observe the local flow
- At startup, synchronize remote → local
- If local error, switch to remote observation
- If both fail, propagate the error
override suspend fun getMyHealthCardOnce(fromCacheFirst: Boolean): HealthCard? {
try {
// 1. Remote attempt
val remoteCard = remote.getMyHealthCardOnce(fromCacheFirst)
if (remoteCard != null) {
// 2. Save in local cache
runCatching { local.upsertMyHealthCard(remoteCard) }
syncMutex.withLock { isRemoteAvailable = true }
return remoteCard
} else {
syncMutex.withLock { isRemoteAvailable = true }
}
} catch (e: Exception) {
syncMutex.withLock { isRemoteAvailable = false }
Log.e(TAG, "Remote getMyHealthCardOnce failed")
}
// 3. Fallback to local cache
return try {
local.getMyHealthCardOnce(fromCacheFirst = true)
} catch (e: Exception) {
Log.e(TAG, "Local getMyHealthCardOnce failed")
null
}
}override suspend fun upsertMyHealthCard(card: HealthCard) {
updateBothRepositories(
localOperation = { local.upsertMyHealthCard(card) },
remoteOperation = { remote.upsertMyHealthCard(card) }
)
}
private suspend fun updateBothRepositories(
localOperation: suspend () -> Unit,
remoteOperation: suspend () -> Unit
) {
// 1. Local operation (mandatory)
try {
localOperation()
} catch (e: Exception) {
Log.e(TAG, "Local operation failed")
throw e
}
// 2. Check remote availability
val shouldUpdateRemote = syncMutex.withLock { isRemoteAvailable }
if (!shouldUpdateRemote) return
// 3. Remote operation (best effort)
try {
remoteOperation()
syncMutex.withLock { isRemoteAvailable = true }
} catch (e: Exception) {
syncMutex.withLock { isRemoteAvailable = false }
Log.e(TAG, "Remote update failed")
throw e
}
}- Manages user preferences (alerts, theme, etc.)
- Local : Jetpack DataStore (Protobuf)
- Remote : Firestore
- Automatic synchronization on startup
data class UserPreferences(
val dangerModePreferences: DangerModePreferences,
val themePreferences: Boolean // Dark mode
)
data class DangerModePreferences(
val alertMode: Boolean,
val inactivityDetection: Boolean,
val automaticSms: Boolean,
val automaticCalls: Boolean
)override val getUserPreferences: Flow<UserPreferences> = flow {
emitAll(
local.getUserPreferences
// Remote → local synchronization on startup
.onStart { syncRemoteToLocal() }
// Fallback to remote if local failure
.catch { error ->
emitAll(
remote.getUserPreferences.catch { throw error }
)
}
)
}override suspend fun setAlertMode(enabled: Boolean) {
updateBothRepositories { setAlertMode(enabled) }
}
private suspend fun updateBothRepositories(
update: suspend UserPreferencesRepository.() -> Unit
) {
// 1. Local update
local.update()
// 2. Check remote availability
val shouldUpdateRemote = syncMutex.withLock { isRemoteAvailable }
if (!shouldUpdateRemote) return
// 3. Remote update (best effort)
if (isRemoteAvailable) {
try {
remote.update()
} catch (e: Exception) {
syncMutex.withLock { isRemoteAvailable = false }
Log.w(TAG, "Remote update failed")
}
}
}private suspend fun syncRemoteToLocal() {
syncMutex.withLock {
try {
// 1. Retrieve remote preferences
remote.getUserPreferences.first().let { remotePrefs ->
// 2. Apply all preferences to local
with(local) {
setAlertMode(remotePrefs.dangerModePreferences.alertMode)
setInactivityDetection(remotePrefs.dangerModePreferences.inactivityDetection)
setAutomaticSms(remotePrefs.dangerModePreferences.automaticSms)
setDarkMode(remotePrefs.themePreferences)
}
}
isRemoteAvailable = true
} catch (e: Exception) {
isRemoteAvailable = false
Log.w(TAG, "Remote update failed")
}
}
}Each hybrid repository holds an isRemoteAvailable flag :
private var isRemoteAvailable = trueStates :
-
true: The remote is available, the synchronization operations are attempted -
false: The remote is unavailable, the remote operations are ignored
┌─────────────────┐
│ Remote │
│ Available │
│ (true) │
└────┬────────────┘
│
│ Remote operation fails
▼
┌─────────────────┐
│ Remote │
│ Unavailable │
│ (false) │
└────┬────────────┘
│
│ Remote operation succeeds
▼
┌─────────────────┐
│ Remote │
│ Available │
│ (true) │
└─────────────────┘
private val syncMutex = Mutex()
// Thread-safe reading
val shouldUpdateRemote = syncMutex.withLock { isRemoteAvailable }
// Thread-safe writing
syncMutex.withLock { isRemoteAvailable = false }The Mutex ensures that only one coroutine at a time can modify the flag.
1. App starts
2. User authenticated → Hybrid repositories initialized
3. ViewModel observes data
4. HybridRepository attempts remote read
5. Success → Data synchronized to local
6. Data displayed in UI
7. isRemoteAvailable = true
1. App starts (no connection)
2. User authenticated → Hybrid repositories initialized
3. ViewModel observes data
4. HybridRepository attempts remote read
5. Failure → Fallback to local
6. Local data displayed in UI
7. isRemoteAvailable = false
8. [Connection restored]
9. Next operation succeeds → isRemoteAvailable = true
// ❌ BAD: Strong coupling
class ContactViewModel(
private val repository: HybridContactRepository)
// ✅ GOOD: Loose coupling
class ContactViewModel(
private val repository: ContactsRepository)
viewModelScope.launch {
try {
repository.addContact(userId, contact)
_uiState.update { it.copy(message = "Add contact") }
} catch (e: Exception) {
_uiState.update {
it.copy(errorMessage = "Locally saved, waiting for remote")
}
}
}// ❌ Bad : Polling
viewModelScope.launch {
while (true) {
val contacts = repository.getAllContacts(userId)
_contacts.value = contacts
delay(1000)
}
}
// ✅ Good : Reactive observation
init {
viewModelScope.launch {
repository.observeContacts(userId).collect { contacts ->
_contacts.value = contacts
}
}
}// Dans MainActivity ou Application
fun initRepositories(user: FirebaseUser?) {
if (user != null) {
// Hybrid mode with sync
ContactRepositoryProvider.initHybrid(context, db)
} else {
// Local mode only
ContactRepositoryProvider.initLocal(context)
}
}private suspend fun syncRemoteToLocal() {
Log.d(TAG, "Starting sync: remote → local")
try {
// Synchronisation
Log.d(TAG, "Sync completed successfully")
} catch (e: Exception) {
Log.e(TAG, "Sync failed: ${e.localizedMessage}")
}
} ┌─────────────────┐
│ User launches │
│ app │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Is user │
│ authenticated? │
└────┬───────┬────┘
│ │
Yes │ │ No
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Initialize │ │ Initialize │
│ Hybrid │ │ Local │
│ Repositories │ │ Repositories │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────┐
│ Try remote first │ │ Local only │
│ Fallback to local│ │ No sync │
└──────────────────┘ └──────────────┘
Warnastrophy's hybrid repositories offer a robust architecture for managing data in both online and offline modes:
- Remote-First strategy for reads with local fallback
- Local-First strategy for writes with best-effort remote sync
- Automatic management of remote availability status
- Transparent synchronization invisible to the UI
- Reactive support via Kotlin Flow
This architecture ensures that the application always works, even in the event of a network problem, while maintaining data consistency whenever possible.