Skip to content

Hybrid Repositories Architecture

NailLaraqui edited this page Dec 15, 2025 · 7 revisions

This document explains the architecture of the Warnastrophy application's hybrid repositories,

which enable seamless synchronization between local and remote storage.

Overview

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

Global architecture

┌─────────────────────────────────────────────────────────────┐
│                        ViewModel                            │
│                  (Business logic)                           │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                  Hybrid Repository                          │
│  ┌────────────────────────────────────────────────────┐     │
│  │  • local ↔ remote synchronization                  │     │
│  │  • Remote availability status management           │     │
│  │  • Fallback strategy                               │     │
│  └────────────────────────────────────────────────────┘     │
└──────────────┬────────────────────────┬─────────────────────┘
               │                        │
               ▼                        ▼
    ┌──────────────────┐    ┌──────────────────┐
    │ Local Repository │    │ Remote Repository│
    │   (Room/         │    │   (Firestore)    │
    │    DataStore)    │    │                  │
    └──────────────────┘    └──────────────────┘

Implemented hybrid repositories

The application uses three hybrid repositories:

1. HybridContactRepository

Manages the user's emergency contacts.

2. HybridHealthCardRepository

Manages the user's health card.

3. HybridUserPreferencesRepository

Manages user preferences (dark mode, alerts, etc.).

Initialization based on authentication

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

Synchronization mechanism

Reading strategy

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      │
                 └──────────────┘

Writing strategy

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

Detailed implementation

1. HybridContactRepository

Characteristics

  • Manages emergency contacts with ID, last name, first name, number, email
  • Supports complete CRUD operations
  • Two-way synchronization

Reading flow

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)
}

Writing flow

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
}

Incremental synchronization

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}")
    }
}

2. HybridHealthCardRepository

Characteristics

  • Manages a single health card per user
  • Use Flow for reactive observation
  • Cache-first mode support

Reactive observation

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 :

  1. Observe the local flow
  2. At startup, synchronize remote → local
  3. If local error, switch to remote observation
  4. If both fail, propagate the error

Single read with cache

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
    }
}

Upsert (Insert or Update)

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
    }
}

3. HybridUserPreferencesRepository

Characteristics

  • Manages user preferences (alerts, theme, etc.)
  • Local : Jetpack DataStore (Protobuf)
  • Remote : Firestore
  • Automatic synchronization on startup

Preferences structure

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
)

Observation with synchronization

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 }
                )
            }
    )
}

Preferences update

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")
        }
    }
}

Complete synchronization

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")
        }
    }
}

Remote availability status management

Availability flag

Each hybrid repository holds an isRemoteAvailable flag :

private var isRemoteAvailable = true

States :

  • true : The remote is available, the synchronization operations are attempted
  • false : The remote is unavailable, the remote operations are ignored

State transitions

     ┌─────────────────┐
     │   Remote        │
     │  Available      │
     │  (true)         │
     └────┬────────────┘
          │
          │ Remote operation fails
          ▼
     ┌─────────────────┐
     │   Remote        │
     │  Unavailable    │
     │  (false)        │
     └────┬────────────┘
          │
          │ Remote operation succeeds
          ▼
     ┌─────────────────┐
     │   Remote        │
     │  Available      │
     │  (true)         │
     └─────────────────┘

Thread-safe synchronization

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.

Usage scenarios

Scenario 1: Startup with connection

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

Scenario 2: Offline startup

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

Best practices

1. Always use the hybrid repository via interface

// ❌ BAD: Strong coupling
class ContactViewModel(
    private val repository: HybridContactRepository)


// ✅ GOOD: Loose coupling
class ContactViewModel(
    private val repository: ContactsRepository)

2. Handle synchronization errors

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")
        }
    }
}

3. Observe instead of polling

// ❌ 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
        }
    }
}

4. Initialize according to authentication status

// 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)
    }
}

5. Log the synchronization operations

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}")
    }
}

Decision diagram

                    ┌─────────────────┐
                    │ User launches   │
                    │      app        │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │ Is user         │
                    │ authenticated?  │
                    └────┬───────┬────┘
                         │       │
                    Yes  │       │  No
                         │       │
                         ▼       ▼
           ┌──────────────┐   ┌──────────────┐
           │ Initialize   │   │ Initialize   │
           │   Hybrid     │   │    Local     │
           │ Repositories │   │ Repositories │
           └──────┬───────┘   └──────┬───────┘
                  │                  │
                  ▼                  ▼
       ┌──────────────────┐  ┌──────────────┐
       │ Try remote first │  │ Local only   │
       │ Fallback to local│  │ No sync      │
       └──────────────────┘  └──────────────┘

Summary

Warnastrophy's hybrid repositories offer a robust architecture for managing data in both online and offline modes:

  1. Remote-First strategy for reads with local fallback
  2. Local-First strategy for writes with best-effort remote sync
  3. Automatic management of remote availability status
  4. Transparent synchronization invisible to the UI
  5. 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.

Resources

Clone this wiki locally