Tema 6. Integración Room + Retrofit2: arquitectura offline-first

  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • Duración aproximada: 4 horas
  • Aplicación de referencia: AppDummy biblioteca personal — proyecto vertebrador.
  • RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA2-a Se ha generado la estructura de clases necesaria para la aplicación.
RA2-b Se han analizado y utilizado las clases que modelan ventanas, menús, alertas y controles.
RA2-e Se han utilizado las técnicas de acceso a servicios de comunicación en red disponibles.
RA2-f Se han almacenado y recuperado datos utilizando algún mecanismo de persistencia.
RA2-g Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas.
RA2-i Se han documentado los procesos necesarios para el desarrollo de las aplicaciones.
RA3-c Se han utilizado clases para la conversión de datos multimedia de un formato a otro.
RA3-d Se han utilizado clases para procesar datos multimedia.
RA3-e Se han utilizado clases para el control de eventos, tipos de media y excepciones, entre otros.
RA3-h Se han depurado y documentado los programas desarrollados.

Dependencias necesarias#

Se continúa desde la última versión de AppDummy, si no la tienes, puedes descargarla desde v7. Ejemplo práctico Tema 5 .

Este tema no añade ninguna biblioteca nueva: todo lo necesario (Room, Retrofit, OkHttp, Gson, Coil y Material 3) se añadió en los T4 y T5. Solo hay que comprobar una cosa en el proyecto y añadir un permiso si no está ya declarado.

 1// build.gradle.kts (módulo app) — comprobación, no hay que añadir nada nuevo
 2
 3dependencies {
 4    // PullToRefreshBox forma parte de Material 3 desde la versión 1.3.0;
 5    // si el catálogo de versiones apunta a una versión anterior (no "material3"), actualízalo
 6    implementation(platform(libs.androidx.compose.bom))
 7    implementation(libs.androidx.material3)
 8
 9    // collectAsStateWithLifecycle() — ya presente desde el Bloque 2
10    implementation(libs.androidx.lifecycle.runtime.compose)
11}

El observador de conectividad de la sección 8 consulta el estado de la red del dispositivo, lo que obliga a declarar un permiso normal (no requiere confirmación del usuario en tiempo de ejecución):

1<!-- AndroidManifest.xml — junto al permiso de INTERNET añadido en T5 -->
2<uses-permission android:name="android.permission.INTERNET" />
3<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

En la sección 3 se añade una columna nueva a la entidad Libro, lo que obliga a incrementar la versión de la base de datos a 2. Como AppDatabase se construye con fallbackToDestructiveMigration(dropAllTables = true), Room borrará y recreará la base de datos: la biblioteca guardada durante las pruebas de T5 se perderá. Es el comportamiento esperado en un proyecto de aprendizaje; en el Anexo B3-A1 se explica cómo escribir una Migration real para no perder los datos.


1. El problema: dos fuentes de datos y ninguna autoridad#

Al terminar el T5 la aplicación ya sabe hacer las dos cosas por separado:

  • Room guarda la biblioteca del usuario y la expone como Flow (T4).
  • Retrofit consulta Open Library y devuelve Libro envuelto en Result<T> (T5).

Lo que todavía no está resuelto es cómo se relacionan ambas. Los datos de un libro cambian con el tiempo: Open Library corrige títulos mal escritos, añade portadas que antes no existían y completa autores. Un libro dado de alta hace seis meses puede tener hoy mejor información en el servidor. Y, al mismo tiempo, la aplicación debe seguir funcionando en el metro, sin cobertura.

Sin una estrategia explícita, esa decisión acaba en el peor sitio posible, la interfaz:

 1// ❌ Antipatrón — la UI decide de dónde vienen los datos
 2@Composable
 3fun PantallaListadoMal(viewModel: LibrosViewModel) {
 4    if (hayConexion()) {
 5        mostrarLibrosDeLaApi()      // ¿y si la petición falla a mitad?
 6    } else {
 7        mostrarLibrosDeRoom()       // ¿y si Room está vacío?
 8    }
 9    // Esta lógica no pertenece aquí: pertenece a la capa de datos
10}

Este código tiene tres defectos graves. Duplica el trabajo, porque hay dos caminos distintos que pintan la misma lista. Es imposible de probar sin un dispositivo con y sin red. Y produce parpadeos: la pantalla muestra primero los datos locales y los sustituye de golpe por los remotos.

La solución es la estrategia offline-first, en la que la capa de datos toma todas esas decisiones de forma transparente para la interfaz.


2. La estrategia offline-first#

Offline-first significa que la aplicación funciona correctamente aunque no haya conexión a Internet. La interfaz siempre observa los datos locales de Room; la red se usa exclusivamente para actualizar esos datos locales.

┌─────────────────────────────────────────────────────────────┐
│                       UI (Composable)                       │
│              observa el StateFlow del ViewModel             │
└────────────────────────────┬────────────────────────────────┘
                             │ collectAsStateWithLifecycle()
                             ▼
┌─────────────────────────────────────────────────────────────┐
│                        ViewModel                            │
│           stateIn() sobre el Flow del Repository            │
└────────────────────────────┬────────────────────────────────┘
                             │ Flow<List<Libro>>
                             ▼
┌─────────────────────────────────────────────────────────────┐
│                     LibrosRepository                        │
│                                                             │
│   observarLibros() ──────► LocalDataSource ──► Room DAO     │
│                                    ▲                        │
│   sincronizarBiblioteca()          │ UPDATE selectivo       │
│   ├─ remoteDataSource.buscarPorIsbn()   (sin tocar          │
│   └─ localDataSource.refrescarDesdeRed() es_favorito        │
│                                    │      ni leido)         │
│                                    └────────────────────────┤
└─────────────────────────────────────────────────────────────┘

Los tres principios#

Room es la única fuente de verdad (single source of truth). La interfaz nunca recibe datos directamente de la red. Si un dato no está en Room, para la interfaz no existe. La red solo sirve para escribir en Room.

La sincronización es un efecto lateral, no una consulta. sincronizarBiblioteca() no devuelve la lista de libros: escribe en Room y termina. La pantalla se actualiza sola porque está observando un Flow de Room, que reemite en cuanto cambia la tabla. Esa es la razón de que el método devuelva un resumen (cuántos libros se han comprobado) y no datos.

Un fallo de red no destruye el estado local. Si la sincronización falla —sin cobertura, error 429 por exceso de peticiones, servidor caído—, Room no se modifica y la interfaz sigue mostrando exactamente lo que mostraba antes. El error se comunica con un Snackbar, no con una pantalla de error.

Offline-first no es lo mismo que “tener caché”. Una caché es una copia que se consulta por si acaso falla la fuente principal. En offline-first la relación se invierte: la copia local es la fuente principal, y la red es un proceso opcional que la mejora. La diferencia se aprecia en el código: no hay ni un solo if (hayConexion) en el camino de lectura.

¿Qué significa “sincronizar” en AppDummy?#

Conviene detenerse aquí, porque la respuesta depende de cada aplicación. En una aplicación de noticias, sincronizar es descargar los últimos titulares. En AppDummy no existe una lista de “libros populares” que descargar: la biblioteca la construye el usuario desde el formulario de T5.

Por lo tanto, sincronizar en AppDummy consiste en refrescar los metadatos de los libros que el usuario ya tiene guardados:

Para cada libro de la biblioteca que tenga ISBN y esté "caducado":
    1. Consultar search.json?q=isbn:<isbn> en Open Library
    2. Si hay resultado → actualizar titulo, autor, year, isbn y cover en Room
    3. NO tocar es_favorito ni leido (son datos que la API no tiene)
    4. Anotar la fecha de la comprobación
    5. Esperar 1,1 segundos antes del siguiente libro (límite de 1 petición/segundo)

Los pasos 3 y 5 son los que dan sentido al tema: el primero justifica el upsertConservandoFavorito que se escribió en T4 y el segundo obliga a razonar sobre los límites de una API real.


3. Preparar la entidad: la marca de tiempo de sincronización#

Para saber qué hay que refrescar hace falta saber cuándo se refrescó por última vez. Se añade una columna a la entidad Libro, la primera modificación que sufre desde T4:

 1import androidx.room.ColumnInfo
 2import androidx.room.Entity
 3import androidx.room.Index
 4import androidx.room.PrimaryKey
 5
 6// ─── data/model/Libro.kt ─────────────────────────────────────────────────────────────────────────
 7@Entity(
 8    tableName = "libros",
 9    indices = [
10        Index(value = ["titulo"]),
11        Index(value = ["autor", "isbn"], unique = false)
12    ]
13)
14data class Libro(
15    @PrimaryKey
16    val id: String,
17
18    @ColumnInfo(name = "titulo")
19    val titulo: String,
20
21    @ColumnInfo(name = "autor")
22    val autor: String,
23
24    @ColumnInfo(name = "year")
25    val year: Int? = 1900,
26
27    @ColumnInfo(name = "isbn")
28    val isbn: String,
29
30    @ColumnInfo(name = "cover")
31    val cover: String? = null,
32
33    // ─── Campos exclusivamente locales ──────────────────────────────────────
34    @ColumnInfo(name = "es_favorito")
35    val esFavorito: Boolean = false,
36
37    @ColumnInfo(name = "leido")
38    val leido: Boolean = false,
39
40    // ─── NUEVO en T6 ────────────────────────────────────────────────────────
41    // Instante (epoch en milisegundos) de la última comprobación contra la API.
42    // 0L significa "nunca sincronizado": es el valor de los libros recién dados
43    // de alta desde el formulario y el de los creados manualmente.
44    @ColumnInfo(name = "actualizado_en")
45    val actualizadoEn: Long = 0L
46)

Al cambiar el esquema hay que incrementar la versión de la base de datos:

 1// ─── data/datasource/local/AppDatabase.kt ────────────────────────────────────────────────────────
 2@Database(
 3    entities = [Libro::class],
 4    version = 2,              // ← era 1 en T4: se ha añadido la columna actualizado_en
 5    exportSchema = true
 6)
 7@TypeConverters(Converters::class)
 8abstract class AppDatabase : RoomDatabase() {
 9    abstract fun libroDao(): LibrosDao
10    // ... resto de la clase sin cambios ...
11}

¿Por qué un Long y no un Date? Guardar un instante como número de milisegundos desde el 1 de enero de 1970 (epoch) evita necesitar un TypeConverter, se ordena y se compara directamente en SQL (WHERE actualizado_en < :limite) y no depende de la zona horaria del dispositivo. La conversión a texto legible es responsabilidad de la interfaz, no de la base de datos.


4. El DAO: consultas de sincronización#

El DAO del T4 ya contenía la pieza más delicada, actualizarDesdeRed, que actualiza solo las columnas procedentes de la API. Ahora se le añade la marca de tiempo y se incorporan tres consultas nuevas:

 1import androidx.room.*
 2import kotlinx.coroutines.flow.Flow
 3
 4// ─── data/datasource/local/LibrosDao.kt — añadir/modificar ───────────────────────────────────────
 5@Dao
 6interface LibrosDao {
 7
 8    // ... consultas de T4 y T5 sin cambios ...
 9
10    // ─── Sincronización (T6) ────────────────────────────────────────────────
11
12    // Devuelve los libros candidatos a refrescarse:
13    //   · deben tener ISBN (sin ISBN no se pueden localizar en Open Library)
14    //   · deben estar "caducados": comprobados antes del instante :anteriorA
15    // Se ordenan del más antiguo al más reciente y se limita la cantidad para no
16    // encadenar decenas de peticiones en una sola sincronización.
17    @Query("""
18        SELECT * FROM libros
19        WHERE isbn <> '' AND actualizado_en < :anteriorA
20        ORDER BY actualizado_en ASC
21        LIMIT :maximo
22    """)
23    suspend fun obtenerCaducados(anteriorA: Long, maximo: Int): List<Libro>
24
25    // Instante de la sincronización más reciente de toda la tabla.
26    // Devuelve null si la tabla está vacía (MAX() sobre cero filas es NULL).
27    @Query("SELECT MAX(actualizado_en) FROM libros")
28    fun observarUltimaSincronizacion(): Flow<Long?>
29
30    // MODIFICADO respecto a T4: se añade la columna actualizado_en.
31    // Sigue sin tocar es_favorito ni leido: son propiedad del usuario.
32    @Query("""
33        UPDATE libros SET
34            titulo = :titulo,
35            autor = :autor,
36            year = :year,
37            isbn = :isbn,
38            cover = :cover,
39            actualizado_en = :actualizadoEn
40        WHERE id = :id
41    """)
42    suspend fun actualizarDesdeRed(
43        id: String,
44        titulo: String,
45        autor: String,
46        year: Int?,
47        isbn: String,
48        cover: String?,
49        actualizadoEn: Long
50    )
51
52    // Marca un libro como comprobado sin modificar sus datos. Se usa cuando Open
53    // Library no devuelve resultados para ese ISBN: el libro se ha comprobado, así
54    // que no debe volver a pedirse en la siguiente sincronización.
55    @Query("UPDATE libros SET actualizado_en = :instante WHERE id = :id")
56    suspend fun marcarComprobado(id: String, instante: Long)
57
58    // MODIFICADO respecto a T4: propaga la marca de tiempo a la actualización
59    @Transaction
60    suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) {
61        val resultados = insertarIgnorando(libros)
62        libros.forEachIndexed { indice, libro ->
63            if (resultados[indice] == -1L) {
64                actualizarDesdeRed(
65                    id = libro.id,
66                    titulo = libro.titulo,
67                    autor = libro.autor,
68                    year = libro.year,
69                    isbn = libro.isbn,
70                    cover = libro.cover,
71                    actualizadoEn = instante
72                )
73            }
74        }
75    }
76}

Las tres reglas que hacen segura la sincronización. Primera: se actualiza con un UPDATE de columnas concretas, nunca con @Upsert, porque @Upsert reescribe la fila entera y borraría es_favorito y leido. Segunda: se actualiza por el id local, no por el identificador que devuelve la API; así un libro dado de alta manualmente (con id del tipo /local/<uuid>) conserva su clave primaria aunque Open Library lo reconozca por su ISBN. Tercera: todo libro comprobado anota la fecha, tanto si se ha actualizado como si no, para que la siguiente sincronización no vuelva a pedirlo.

Todo esto se expone al repositorio a través de LocalDataSource, que sigue siendo un envoltorio sin lógica propia:

 1// ─── data/datasource/local/LocalDataSource.kt — añadir ───────────────────────────────────────────
 2class LocalDataSource(private val dao: LibrosDao) {
 3
 4    // ... métodos de T4 y T5 sin cambios ...
 5
 6    // ─── Sincronización (T6) ────────────────────────────────────────────────
 7
 8    suspend fun obtenerCaducados(anteriorA: Long, maximo: Int): List<Libro> =
 9        dao.obtenerCaducados(anteriorA, maximo)
10
11    fun observarUltimaSincronizacion(): Flow<Long?> = dao.observarUltimaSincronizacion()
12
13    suspend fun marcarComprobado(id: String, instante: Long) =
14        dao.marcarComprobado(id, instante)
15
16    // Recibe el id LOCAL y los datos REMOTOS por separado: son dos cosas distintas
17    suspend fun refrescarDesdeRed(id: String, datos: Libro, instante: Long) =
18        dao.actualizarDesdeRed(
19            id = id,
20            titulo = datos.titulo,
21            autor = datos.autor,
22            year = datos.year,
23            isbn = datos.isbn,
24            cover = datos.cover,
25            actualizadoEn = instante
26        )
27
28    // Escritura — delega directamente en el DAO - Modificación T6
29    suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) =
30        dao.upsertConservandoFavorito(libros, instante)
31}

5. El Repository: el corazón de la arquitectura#

LibrosRepository es la única clase que conoce a la vez LocalDataSource y RemoteDataSource, y por tanto la única que puede implementar la estrategia. Primero se define el tipo que resume el resultado de una sincronización:

 1// ─── data/repository/ResultadoSincronizacion.kt ──────────────────────────────────────────────────
 2// Resumen de lo ocurrido durante una sincronización. NO contiene libros: los datos
 3// viajan a la UI por el Flow de Room, no por este objeto.
 4data class ResultadoSincronizacion(
 5    val actualizados: Int = 0,   // libros cuyos datos ha devuelto la API
 6    val sinCambios: Int = 0,     // libros comprobados sin resultado en la API
 7    val error: String? = null    // mensaje si la sincronización se interrumpió
 8) {
 9    val comprobados: Int get() = actualizados + sinCambios
10    val haFallado: Boolean get() = error != null
11}

Y a continuación, el repositorio completo del bloque:

  1import com.ejemplo.appdummy.data.datasource.local.LocalDataSource
  2import com.ejemplo.appdummy.data.datasource.remote.RemoteDataSource
  3import com.ejemplo.appdummy.data.model.Libro
  4import kotlinx.coroutines.delay
  5import kotlinx.coroutines.flow.Flow
  6import retrofit2.HttpException
  7import java.io.IOException
  8import kotlin.time.Duration.Companion.days
  9import kotlin.time.Duration.Companion.milliseconds
 10
 11// ─── data/repository/LibrosRepository.kt ─────────────────────────────────────────────────────────
 12class LibrosRepository(
 13    private val localDataSource: LocalDataSource,
 14    private val remoteDataSource: RemoteDataSource
 15) {
 16    companion object {
 17        // Un libro se considera "caducado" pasada una semana desde su comprobación
 18        private val PERIODO_VALIDEZ = 7.days
 19
 20        // Tope de peticiones por sincronización: con la pausa de 1,1 s, 20 libros
 21        // son unos 22 segundos. El resto se refrescará en la siguiente pasada.
 22        private const val MAXIMO_POR_SINCRONIZACION = 20
 23
 24        // Open Library admite 1 petición/segundo a las apps anónimas y 3 a las
 25        // identificadas con User-Agent. Se deja margen sobre el límite estricto.
 26        private val PAUSA_ENTRE_PETICIONES = 1_100.milliseconds
 27    }
 28
 29    // ─── Lectura local: la UI SIEMPRE observa desde Room ────────────────────
 30
 31    fun observarLibros(): Flow<List<Libro>> = localDataSource.observarTodos()
 32    fun observarFavoritos(): Flow<List<Libro>> = localDataSource.observarFavoritos()
 33    fun observarPorId(id: String): Flow<Libro?> = localDataSource.observarPorId(id)
 34
 35    // NUEVO en T6: el filtrado por título deja de hacerse en la UI y pasa a SQL
 36    fun observarPorTitulo(texto: String): Flow<List<Libro>> =
 37        localDataSource.observarPorTitulo(texto)
 38
 39    fun observarUltimaSincronizacion(): Flow<Long?> =
 40        localDataSource.observarUltimaSincronizacion()
 41
 42    // ─── Escritura local ────────────────────────────────────────────────────
 43
 44    suspend fun toggleFavorito(id: String) = localDataSource.toggleFavorito(id)
 45    suspend fun toggleLeido(id: String) = localDataSource.toggleLeido(id)
 46    suspend fun obtenerAutores(): List<String>? = localDataSource.obtenerAutores()
 47    suspend fun obtenerPorId(id: String): Libro? = localDataSource.obtenerPorId(id)
 48    suspend fun guardarLibro(libro: Libro) = localDataSource.guardar(libro)
 49
 50    // ─── Lectura remota (T5) ────────────────────────────────────────────────
 51
 52    suspend fun buscarEnApiPorTitulo(titulo: String): Result<List<Libro>> =
 53        ejecutarPeticion { remoteDataSource.buscarPorTitulo(titulo) }
 54
 55    suspend fun buscarEnApiPorIsbn(isbn: String): Result<Libro?> =
 56        ejecutarPeticion { remoteDataSource.buscarPorIsbn(isbn) }
 57
 58    // ─── Sincronización offline-first (T6) ──────────────────────────────────
 59
 60    // Refresca los metadatos de los libros guardados. No devuelve libros:
 61    // escribe en Room y es el Flow de Room quien avisa a la interfaz.
 62    //
 63    // forzar = false → solo los libros caducados (uso normal, al abrir la app)
 64    // forzar = true  → todos los libros con ISBN (pull-to-refresh del usuario)
 65    suspend fun sincronizarBiblioteca(forzar: Boolean = false): ResultadoSincronizacion {
 66
 67        val ahora = System.currentTimeMillis()
 68        val limite = if (forzar) ahora else ahora - PERIODO_VALIDEZ.inWholeMilliseconds
 69
 70        val pendientes = localDataSource.obtenerCaducados(limite, MAXIMO_POR_SINCRONIZACION)
 71        if (pendientes.isEmpty()) return ResultadoSincronizacion()
 72
 73        var actualizados = 0
 74        var sinCambios = 0
 75
 76        for ((indice, libro) in pendientes.withIndex()) {
 77
 78            // Respeto del rate limit: se espera ANTES de cada petición menos la primera
 79            if (indice > 0) delay(PAUSA_ENTRE_PETICIONES)
 80
 81            // getOrElse permite abandonar el bucle en cuanto falla la red. Lo ya
 82            // escrito en Room se conserva: no hay nada que deshacer.
 83            val remoto = ejecutarPeticion { remoteDataSource.buscarPorIsbn(libro.isbn) }
 84                .getOrElse { error ->
 85                    return ResultadoSincronizacion(actualizados, sinCambios, error.message)
 86                }
 87
 88            val instante = System.currentTimeMillis()
 89
 90            if (remoto == null) {
 91                // La API no conoce ese ISBN: se anota la comprobación y se continúa
 92                localDataSource.marcarComprobado(libro.id, instante)
 93                sinCambios++
 94            } else {
 95                localDataSource.refrescarDesdeRed(
 96                    id = libro.id,                       // ← id LOCAL, no el de la API
 97                    // Si la API devolviese el ISBN vacío, se conserva el del usuario
 98                    datos = remoto.copy(isbn = remoto.isbn.ifBlank { libro.isbn }),
 99                    instante = instante
100                )
101                actualizados++
102            }
103        }
104
105        return ResultadoSincronizacion(actualizados, sinCambios)
106    }
107
108    // ─── Traducción de excepciones a Result (T5, sin cambios) ───────────────
109
110    private suspend fun <T> ejecutarPeticion(bloque: suspend () -> T): Result<T> =
111        try {
112            Result.success(bloque())
113        } catch (e: IOException) {
114            Result.failure(Exception("Sin conexión a internet. Comprueba la red."))
115        } catch (e: HttpException) {
116            Result.failure(Exception(mensajeDeError(e.code())))
117        }
118
119    private fun mensajeDeError(codigo: Int): String = when (codigo) {
120        403 -> "Acceso denegado: se ha superado el límite de peticiones."
121        404 -> "Recurso no encontrado en Open Library."
122        429 -> "Demasiadas peticiones. Inténtalo dentro de unos segundos."
123        in 500..599 -> "Error del servidor de Open Library ($codigo)."
124        else -> "Error HTTP $codigo"
125    }
126}

Conviene fijarse en cuatro decisiones de diseño:

delay() en lugar de peticiones en paralelo. Sería tentador lanzar las veinte peticiones a la vez con async/awaitAll y terminar en un segundo. Con Open Library eso significa recibir un HTTP 429 casi con seguridad. Una sincronización lenta que funciona es mejor que una rápida que el servidor rechaza. Como delay() es una función suspend, no bloquea ningún hilo: la corrutina simplemente se aparta y el hilo queda libre para otras tareas.

El primer fallo aborta el proceso. Si la red se ha caído, insistir con los diecinueve libros restantes solo sirve para acumular timeouts. Se devuelve el resultado parcial —lo ya escrito en Room es válido— y se informa al usuario.

getOrElse en lugar de try/catch. El try/catch ya está encapsulado en ejecutarPeticion, de modo que el bucle trabaja con Result<T>. getOrElse es una función inline, así que el return de su lambda es un retorno no local: sale directamente de sincronizarBiblioteca(), que es justo lo que se busca.

El resultado no contiene libros. Es la consecuencia práctica del principio de la fuente única de verdad. Si sincronizarBiblioteca() devolviera List<Libro>, el ViewModel tendría dos orígenes posibles para la misma lista y volveríamos al antipatrón de la sección 1.


6. El ViewModel: estado de sincronización y búsqueda en SQL#

LibrosViewModel gana dos responsabilidades nuevas: exponer el estado de la sincronización (para el indicador de pull-to-refresh) y trasladar el filtro de búsqueda desde la interfaz hasta Room.

  1import androidx.lifecycle.ViewModel
  2import androidx.lifecycle.ViewModelProvider
  3import androidx.lifecycle.viewModelScope
  4import androidx.lifecycle.viewmodel.initializer
  5import androidx.lifecycle.viewmodel.viewModelFactory
  6import com.ejemplo.appdummy.AppDummyApplication
  7import com.ejemplo.appdummy.data.model.Libro
  8import com.ejemplo.appdummy.data.repository.LibrosRepository
  9import com.ejemplo.appdummy.data.repository.ResultadoSincronizacion
 10import com.ejemplo.appdummy.utils.ObservadorConectividad
 11import kotlinx.coroutines.ExperimentalCoroutinesApi
 12import kotlinx.coroutines.FlowPreview
 13import kotlinx.coroutines.flow.*
 14import kotlinx.coroutines.launch
 15import kotlin.time.Duration.Companion.milliseconds
 16
 17// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
 18@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
 19class LibrosViewModel(
 20    private val repository: LibrosRepository,
 21    observadorConectividad: ObservadorConectividad
 22) : ViewModel() {
 23
 24    // ─── Texto de búsqueda ──────────────────────────────────────────────────
 25    private val _busqueda = MutableStateFlow("")
 26    val busqueda: StateFlow<String> = _busqueda.asStateFlow()
 27
 28    // ─── Estado de la sincronización ────────────────────────────────────────
 29    private val _estaSincronizando = MutableStateFlow(false)
 30    val estaSincronizando: StateFlow<Boolean> = _estaSincronizando.asStateFlow()
 31
 32    // ─── Eventos de un solo consumo (Snackbar), heredado de T4 ──────────────
 33    // replay = 0: los eventos no se repiten para nuevos colectores
 34    // extraBufferCapacity = 1: evita suspensión si no hay colector en ese instante
 35    private val _eventos = MutableSharedFlow<LibrosEvento>(
 36        replay = 0,
 37        extraBufferCapacity = 1
 38    )
 39    val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
 40
 41    // Estado del campo de búsqueda — independiente del UiState principal
 42    private val _autores = MutableStateFlow(listOf("Todos"))
 43    val autores: StateFlow<List<String>> = _autores.asStateFlow()
 44
 45    // ─── Búsqueda por autor ─────────────────────────────────────────────────
 46    private val _autorSeleccionado = MutableStateFlow("Todos")
 47    val autorSeleccionado: StateFlow<String> = _autorSeleccionado.asStateFlow()
 48
 49    // ─── Conectividad: la UI la usa para avisar, NO para elegir la fuente ───
 50    val hayConexion: StateFlow<Boolean> = observadorConectividad.hayConexion
 51        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
 52
 53    // ─── Instante de la última sincronización, observado desde Room ─────────
 54    val ultimaSincronizacion: StateFlow<Long?> = repository.observarUltimaSincronizacion()
 55        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
 56
 57    // ─── Estado principal: la búsqueda se resuelve en SQL ───────────────────
 58    // flatMapLatest sustituye el Flow anterior por uno nuevo cada vez que cambia el
 59    // texto: al escribir se cancela la consulta previa y se abre otra sobre Room.
 60    // El resultado sigue siendo reactivo (si cambia la tabla, vuelve a emitir).
 61    val uiState: StateFlow<LibrosUiState> = _busqueda
 62        .debounce(300.milliseconds)      // no consultar en cada pulsación
 63        .distinctUntilChanged()
 64        .flatMapLatest { texto ->
 65            if (texto.isBlank()) repository.observarLibros()
 66            else repository.observarPorTitulo(texto)
 67        }
 68        .map<List<Libro>, LibrosUiState> {
 69            cargarAutores() // cargar autores para el filtro de la UI
 70            LibrosUiState.Exito(it)
 71        }
 72        .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
 73        .stateIn(
 74            scope = viewModelScope,
 75            started = SharingStarted.WhileSubscribed(5_000),
 76            initialValue = LibrosUiState.Cargando
 77        )
 78
 79    init {
 80        // Sincronización silenciosa al crear el ViewModel: solo los caducados
 81        sincronizar(forzar = false)
 82    }
 83
 84    fun cargarAutores() {
 85        viewModelScope.launch {
 86            val autores = repository.obtenerAutores() ?: emptyList()
 87            _autores.value = listOf("Todos") + autores
 88        }
 89    }
 90
 91    fun actualizarAutorSeleccionado(autor: String) {
 92        _autorSeleccionado.value = autor
 93    }
 94
 95    fun actualizarBusqueda(texto: String) {
 96        _busqueda.value = texto
 97    }
 98
 99    fun toggleFavorito(libro: Libro) {
100        viewModelScope.launch {
101            repository.toggleFavorito(libro.id)
102            // No es necesario actualizar el uiState manualmente:
103            // el Flow de Room detecta el cambio y emite la nueva lista automáticamente
104
105            _eventos.emit(
106                LibrosEvento.MostrarSnackbar(
107                    if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
108                    else "\"${libro.titulo}\" añadido a favoritos"
109                )
110            )
111        }
112    }
113
114    fun toggleLeido(libro: Libro) {
115        viewModelScope.launch {
116            repository.toggleLeido(libro.id)
117            _eventos.emit(
118                LibrosEvento.MostrarSnackbar(
119                    if (libro.leido) "\"${libro.titulo}\" marcado como no leído"
120                    else "\"${libro.titulo}\" marcado como leído"
121                )
122            )
123        }
124    }
125
126    // ─── Sincronización ─────────────────────────────────────────────────────
127
128    fun sincronizar(forzar: Boolean = true) {
129        // Guarda de reentrada: evita lanzar dos sincronizaciones simultáneas si el
130        // usuario arrastra la lista mientras ya se está sincronizando
131        if (_estaSincronizando.value) return
132
133        viewModelScope.launch {
134            if (!hayConexion.value) {
135                _eventos.emit(
136                    LibrosEvento.MostrarSnackbar("Sin conexión: se muestran los datos guardados.")
137                )
138                return@launch
139            }
140
141            _estaSincronizando.value = true
142            try {
143                val resultado = repository.sincronizarBiblioteca(forzar)
144                // La sincronización automática del init solo avisa si ha hecho algo
145                if (forzar || resultado.comprobados > 0 || resultado.haFallado) {
146                    _eventos.emit(LibrosEvento.MostrarSnackbar(mensajeDe(resultado)))
147                }
148            } finally {
149                // finally garantiza que el indicador se apaga aunque haya excepción
150                _estaSincronizando.value = false
151            }
152        }
153    }
154
155    private fun mensajeDe(resultado: ResultadoSincronizacion): String = when {
156        resultado.haFallado -> resultado.error ?: "No se ha podido sincronizar."
157        resultado.comprobados == 0 -> "La biblioteca ya está actualizada."
158        resultado.actualizados == 0 -> "Comprobados ${resultado.comprobados} libros, sin cambios."
159        else -> "Actualizados ${resultado.actualizados} de ${resultado.comprobados} libros."
160    }
161
162    companion object {
163        val Factory: ViewModelProvider.Factory = viewModelFactory {
164            initializer {
165                val app = checkNotNull(
166                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
167                ) as AppDummyApplication
168                LibrosViewModel(
169                    repository = app.container.librosRepository,
170                    observadorConectividad = app.container.observadorConectividad
171                )
172            }
173        }
174    }
175}

¿Por qué flatMapLatest y no filtrar la lista en el composable? Filtrar en la interfaz obliga a traer toda la tabla a memoria y recorrerla con filter en cada recomposición. Con veinte libros da igual; con dos mil, no. Delegar el filtro en Room aprovecha el índice sobre la columna titulo creado en T4 y devuelve solo las filas necesarias. Además, la lista filtrada sigue siendo reactiva: si mientras se busca “Weir” se marca un favorito, Room reemite y la pantalla se actualiza.

flatMapLatest está marcado como @ExperimentalCoroutinesApi y debounce como @FlowPreview. Ambas son API estables en la práctica, pero el compilador exige declararlo con @OptIn.


7. La interfaz: pull-to-refresh y aviso de conexión#

PullToRefreshBox es el contenedor de Material 3 que detecta el gesto de arrastre hacia abajo y muestra el indicador circular. Recibe el estado (isRefreshing) y la acción (onRefresh); todo lo demás lo gestiona internamente.

  1import android.text.format.DateUtils
  2import androidx.compose.animation.AnimatedVisibility
  3import androidx.compose.foundation.layout.*
  4import androidx.compose.foundation.lazy.LazyColumn
  5import androidx.compose.foundation.lazy.LazyRow
  6import androidx.compose.foundation.lazy.grid.*
  7import androidx.compose.foundation.lazy.items
  8import androidx.compose.material.icons.Icons
  9import androidx.compose.material.icons.automirrored.filled.MenuBook
 10import androidx.compose.material.icons.filled.*
 11import androidx.compose.material3.*
 12import androidx.compose.material3.pulltorefresh.PullToRefreshBox
 13import androidx.compose.runtime.*
 14import androidx.compose.ui.*
 15import androidx.compose.ui.tooling.preview.Preview
 16import androidx.compose.ui.unit.dp
 17import androidx.lifecycle.compose.collectAsStateWithLifecycle
 18import androidx.lifecycle.viewmodel.compose.viewModel
 19import com.ejemplo.appdummy.screens.componentes.ItemLibro
 20import kotlinx.coroutines.*
 21
 22// ─── screens/listado/PantallaListado.kt ──────────────────────────────────────────────────────────
 23@OptIn(ExperimentalMaterial3Api::class)
 24@Composable
 25fun PantallaListado(
 26    viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory),
 27    onNavegaADetalle: (String) -> Unit = {},  // callback de navegación (se conecta al NavHost en T3)
 28    onNavegaANuevoLibro: () -> Unit = {}      // ← nuevo callback
 29) {
 30    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 31    val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
 32    val autorSeleccionado by viewModel.autorSeleccionado.collectAsStateWithLifecycle()
 33    val autores by viewModel.autores.collectAsStateWithLifecycle()
 34    val estaSincronizando by viewModel.estaSincronizando.collectAsStateWithLifecycle()
 35    val hayConexion by viewModel.hayConexion.collectAsStateWithLifecycle()
 36    val ultimaSincronizacion by viewModel.ultimaSincronizacion.collectAsStateWithLifecycle()
 37
 38    val scope = rememberCoroutineScope()
 39    val snackbarHostState = remember { SnackbarHostState() }
 40    var snackbarJob by remember { mutableStateOf<Job?>(null) }
 41
 42    // Consumo de los eventos de un solo uso emitidos por el ViewModel.
 43    // LaunchedEffect(Unit): la corrutina se lanza una vez y vive mientras el
 44    // composable permanezca en la composición.
 45    LaunchedEffect(Unit) {
 46        viewModel.eventos.collect { evento ->
 47            when (evento) {
 48                is LibrosEvento.MostrarSnackbar -> {
 49                    // Cancelar snackbar previo si existe
 50                    snackbarJob?.cancel()
 51                    // Descarta el Snackbar visible en pantalla
 52                    snackbarHostState.currentSnackbarData?.dismiss()
 53                    // Lanzar un nuevo Snackbar
 54                    snackbarJob = scope.launch {
 55                        snackbarHostState.showSnackbar(evento.mensaje)
 56                    }
 57                }
 58            }
 59        }
 60    }
 61
 62    Scaffold(
 63        topBar = {
 64            TopAppBar(
 65                title = {
 66                    Column {
 67                        Text("AppDummy")
 68                        Text(
 69                            text = textoUltimaSincronizacion(ultimaSincronizacion),
 70                            style = MaterialTheme.typography.labelSmall,
 71                            color = MaterialTheme.colorScheme.onSurfaceVariant
 72                        )
 73                    }
 74                },
 75                actions = {
 76                    IconButton(
 77                        onClick = { viewModel.sincronizar() },
 78                        enabled = !estaSincronizando
 79                    ) {
 80                        Icon(Icons.Default.Refresh, contentDescription = "Sincronizar")
 81                    }
 82                    IconButton(onClick = { }) {
 83                        Icon(Icons.Default.AccountCircle, contentDescription = "Perfil")
 84                    }
 85                }
 86            )
 87        },
 88        floatingActionButton = {
 89            if (snackbarHostState.currentSnackbarData == null)
 90                AnimatedVisibility(snackbarHostState.currentSnackbarData == null) {
 91                    FloatingActionButton(onClick = onNavegaANuevoLibro) {
 92                        Icon(Icons.Default.Add, contentDescription = "Añadir libro")
 93                    }
 94                }
 95        },
 96        snackbarHost = { SnackbarHost(snackbarHostState) }
 97    ) { paddingValues ->
 98        Column(modifier = Modifier.padding(paddingValues)) {
 99
100            // ─── Aviso de falta de conexión ─────────────────────────────────
101            AnimatedVisibility(visible = !hayConexion) { BannerSinConexion() }
102
103            // ─── Búsqueda (el filtrado lo resuelve Room) ────────────────────
104            OutlinedTextField(
105                value = busqueda,
106                onValueChange = viewModel::actualizarBusqueda,
107                modifier = Modifier
108                    .fillMaxWidth()
109                    .padding(horizontal = 16.dp, vertical = 8.dp),
110                placeholder = { Text("Buscar en mi biblioteca...") },
111                leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
112                trailingIcon = {
113                    AnimatedVisibility(visible = busqueda.isNotEmpty()) {
114                        IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
115                            Icon(Icons.Default.Clear, contentDescription = "Borrar búsqueda")
116                        }
117                    }
118                },
119                singleLine = true
120            )
121
122            // ─── Chips de autores ───────────────────────────────────────────
123            LazyRow(
124                contentPadding = PaddingValues(horizontal = 16.dp),
125                horizontalArrangement = Arrangement.spacedBy(8.dp),
126                modifier = Modifier.padding(bottom = 8.dp)
127            ) {
128                items(autores) { autor ->
129                    FilterChip(
130                        selected = autor == autorSeleccionado,
131                        onClick = { viewModel.actualizarAutorSeleccionado(autor) },
132                        label = { Text(autor) }
133                    )
134                }
135            }
136
137            // ─── Contenido con pull-to-refresh ──────────────────────────────
138            PullToRefreshBox(
139                isRefreshing = estaSincronizando,
140                onRefresh = { viewModel.sincronizar(forzar = true) },
141                modifier = Modifier.weight(1f)
142            ) {
143                // when exhaustivo sobre la sealed class
144                when (val estado = uiState) {
145                    is LibrosUiState.Cargando ->
146                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
147                            CircularProgressIndicator()
148                        }
149
150                    is LibrosUiState.Error ->
151                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
152                            Text(estado.mensaje)
153                        }
154
155                    is LibrosUiState.Exito -> {
156                        // IMPORTANTE: el contenido de PullToRefreshBox debe ser
157                        // desplazable para que el gesto se detecte. Por eso el
158                        // estado vacío también va dentro del LazyColumn.
159
160                        // Filtrado de libros según la búsqueda y el autor seleccionado en UI,
161                        // aunque la búsqueda ya se resuelve en SQL, el filtrado por autor
162                        // se hace aquí en la UI. Esto permite que el filtrado por autor sea
163                        // reactivo y no requiera una nueva consulta a la base de datos.
164                        val librosFiltrados = estado.libros.filter {
165                            val coincideBusqueda =
166                                busqueda.isBlank() || it.titulo.contains(
167                                    busqueda,
168                                    ignoreCase = true
169                                )
170                            val coincideGenero =
171                                autorSeleccionado == "Todos" || it.autor == autorSeleccionado
172                            coincideBusqueda && coincideGenero
173                        }
174                        if (librosFiltrados.isEmpty()) {
175                            LazyColumn(
176                                contentPadding = PaddingValues(horizontal = 8.dp),
177                            ) {
178                                item {
179                                    EstadoVacio(
180                                        busqueda = busqueda,
181                                        onAnyadir = onNavegaANuevoLibro
182                                    )
183                                }
184                            }
185                        } else {
186                            LazyVerticalGrid(
187                                contentPadding = PaddingValues(horizontal = 8.dp),
188                                columns = GridCells.Fixed(2),
189                                horizontalArrangement = Arrangement.spacedBy(8.dp),
190                                verticalArrangement = Arrangement.spacedBy(8.dp)
191                            ) {
192                                items(librosFiltrados, key = { it.id }) { libro ->
193                                    ItemLibro(
194                                        libro = libro,
195                                        onClickItem = { onNavegaADetalle(libro.id) },
196                                        onToggleFavorito = { viewModel.toggleFavorito(libro) },
197                                        onToggleLeido = { viewModel.toggleLeido(libro) }
198                                    )
199                                }
200                            }
201                        }
202                    }
203                }
204            }
205        }
206    }
207}
208
209// Texto legible de la última sincronización. DateUtils.getRelativeTimeSpanString
210// devuelve cadenas como "hace 3 horas", ya traducidas por el sistema al idioma
211// del dispositivo, sin necesidad de formateadores ni de la API de java.time.
212private fun textoUltimaSincronizacion(instante: Long?): String =
213    if (instante == null || instante == 0L) "Sin sincronizar"
214    else "Actualizado ${DateUtils.getRelativeTimeSpanString(instante)}"
215
216@Composable
217private fun BannerSinConexion() {
218    Surface(
219        color = MaterialTheme.colorScheme.errorContainer,
220        modifier = Modifier.fillMaxWidth()
221    ) {
222        Row(
223            verticalAlignment = Alignment.CenterVertically,
224            horizontalArrangement = Arrangement.spacedBy(8.dp),
225            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
226        ) {
227            Icon(
228                imageVector = Icons.Default.CloudOff,
229                contentDescription = null,
230                tint = MaterialTheme.colorScheme.onErrorContainer
231            )
232            Text(
233                text = "Sin conexión. Se muestran los datos guardados.",
234                style = MaterialTheme.typography.bodySmall,
235                color = MaterialTheme.colorScheme.onErrorContainer
236            )
237        }
238    }
239}
240
241@Composable
242private fun EstadoVacio(busqueda: String, onAnyadir: () -> Unit) {
243    Column(
244        horizontalAlignment = Alignment.CenterHorizontally,
245        modifier = Modifier
246            .fillMaxWidth()
247            .padding(32.dp)
248    ) {
249        Icon(
250            imageVector = Icons.AutoMirrored.Filled.MenuBook,
251            contentDescription = null,
252            modifier = Modifier.size(64.dp),
253            tint = MaterialTheme.colorScheme.onSurfaceVariant
254        )
255        Spacer(Modifier.height(16.dp))
256        Text(
257            text = if (busqueda.isBlank()) "Tu biblioteca está vacía"
258            else "Ningún libro coincide con \"$busqueda\"",
259            style = MaterialTheme.typography.titleMedium
260        )
261        Spacer(Modifier.height(8.dp))
262        Text(
263            text = "Puedes buscarlo en Open Library y añadirlo a tu biblioteca.",
264            style = MaterialTheme.typography.bodySmall,
265            color = MaterialTheme.colorScheme.onSurfaceVariant
266        )
267        Spacer(Modifier.height(16.dp))
268        Button(onClick = onAnyadir) { Text("Añadir libro") }
269    }
270}

Dos detalles fáciles de pasar por alto. El primero: PullToRefreshBox necesita que su contenido sea desplazable verticalmente; si el estado vacío se pinta con un Box fuera del LazyColumn, el gesto no se detecta y parece que el componente está roto. El segundo: el aviso de conexión y el botón de sincronizar son lo único que depende de hayConexion. La lista no cambia de origen, ni se oculta, ni muestra un error. Ese es exactamente el comportamiento offline-first.


8. Observar la conectividad con callbackFlow#

Android informa de los cambios de red mediante ConnectivityManager y una clase de callbacks. Para integrarlo en la arquitectura hay que convertir esos callbacks en un Flow, y la herramienta para ello es el constructor callbackFlow.

 1import android.content.Context
 2import android.net.ConnectivityManager
 3import android.net.Network
 4import android.net.NetworkCapabilities
 5import kotlinx.coroutines.channels.awaitClose
 6import kotlinx.coroutines.flow.Flow
 7import kotlinx.coroutines.flow.callbackFlow
 8import kotlinx.coroutines.flow.conflate
 9import kotlinx.coroutines.flow.distinctUntilChanged
10
11// ─── utils/ObservadorConectividad.kt ─────────────────────────────────────────────────────────────
12// Traduce los callbacks de ConnectivityManager a un Flow<Boolean>.
13// true  → hay una red conectada y VALIDADA (con acceso real a internet)
14// false → no hay red, o la hay pero sin salida a internet (portal cautivo)
15class ObservadorConectividad(context: Context) {
16
17    private val connectivityManager =
18        context.getSystemService(ConnectivityManager::class.java)
19
20    val hayConexion: Flow<Boolean> = callbackFlow {
21
22        val callback = object : ConnectivityManager.NetworkCallback() {
23
24            override fun onAvailable(network: Network) { trySend(true) }
25
26            override fun onLost(network: Network) { trySend(false) }
27
28            override fun onUnavailable() { trySend(false) }
29
30            // NET_CAPABILITY_VALIDATED: el sistema ha comprobado que esta red tiene
31            // salida real a internet. Estar conectado a un wifi NO basta.
32            override fun onCapabilitiesChanged(
33                network: Network,
34                capacidades: NetworkCapabilities
35            ) {
36                trySend(
37                    capacidades.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
38                )
39            }
40        }
41
42        // Valor inicial: el Flow debe emitir el estado actual sin esperar a que algo
43        // cambie, o la UI mostraría "sin conexión" hasta el primer evento del sistema
44        trySend(estadoActual())
45
46        connectivityManager.registerDefaultNetworkCallback(callback)
47
48        // awaitClose es OBLIGATORIO en callbackFlow: mantiene viva la corrutina y,
49        // al cancelarse el Flow (por ejemplo, cuando el ViewModel muere), ejecuta el
50        // bloque para dar de baja el callback y no filtrar memoria.
51        awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
52    }
53        .distinctUntilChanged()   // ignora repeticiones del mismo estado
54        .conflate()               // si llegan varios cambios seguidos, solo el último
55
56    private fun estadoActual(): Boolean {
57        val red = connectivityManager.activeNetwork ?: return false
58        val capacidades = connectivityManager.getNetworkCapabilities(red) ?: return false
59        return capacidades.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
60    }
61}

callbackFlow es el puente estándar entre las API de Android y las corrutinas. El patrón es siempre el mismo y merece la pena memorizarlo: registrar el listener, emitir con trySend() desde los callbacks —que se ejecutan en un hilo cualquiera, por eso no se usa send(), que es suspend— y liberar el recurso dentro de awaitClose { }. Olvidar awaitClose provoca un error en tiempo de ejecución: callbackFlow exige explícitamente que se llame.

El observador se registra en el AppContainer junto al resto de dependencias:

 1import android.content.Context
 2import okhttp3.OkHttpClient
 3
 4// ─── data/di/AppContainer.kt ─────────────────────────────────────────────────────────────────────
 5interface AppContainer {
 6    val librosRepository: LibrosRepository
 7    val okHttpClient: OkHttpClient                       // compartido con Coil (T5)
 8    val observadorConectividad: ObservadorConectividad   // ← NUEVO en T6
 9}
10
11class DefaultAppContainer(private val context: Context) : AppContainer {
12
13    // ─── Capa local (T4) ────────────────────────────────────────────────────
14    private val database by lazy { AppDatabase.getDatabase(context) }
15    private val localDataSource: LocalDataSource by lazy {
16        LocalDataSource(database.libroDao())
17    }
18
19    // ─── Capa remota (T5) ───────────────────────────────────────────────────
20    override val okHttpClient: OkHttpClient by lazy { RetrofitClient.okHttpClient }
21
22    private val remoteDataSource: RemoteDataSource by lazy {
23        RemoteDataSource(RetrofitClient.openLibraryApiService)
24    }
25
26    // ─── Repositorio: coordina ambos orígenes ───────────────────────────────
27    override val librosRepository: LibrosRepository by lazy {
28        LibrosRepository(localDataSource, remoteDataSource)
29    }
30
31    // ─── Servicios del sistema (T6) ─────────────────────────────────────────
32    // Se usa applicationContext: el observador vive tanto como la aplicación, así
33    // que guardar el contexto de una Activity provocaría una fuga de memoria.
34    override val observadorConectividad: ObservadorConectividad by lazy {
35        ObservadorConectividad(context.applicationContext)
36    }
37}

9. ¿Y la caché HTTP? Room ya es la caché#

Es una pregunta razonable después de configurar OkHttpClient en T5: ¿no debería activarse también la caché en disco de OkHttp? La respuesta, en una arquitectura offline-first, es casi siempre no para los datos y sí para las imágenes.

Contenido ¿Quién lo cachea? Motivo
Respuestas JSON de search.json Room Ya se guardan como filas de la tabla libros, consultables, filtrables y editables por el usuario. Una copia adicional del JSON sería redundante y podría contradecir a Room.
Carátulas de covers.openlibrary.org Coil Son binarios grandes que no tiene sentido meter en SQLite. Coil los guarda en memoria y en disco por sí solo.

Añadir una caché HTTP para el JSON introduciría además un problema sutil: OkHttp podría devolver una respuesta cacheada mientras la aplicación cree estar comprobando datos frescos, de modo que actualizado_en registraría una comprobación que en realidad no ha llegado al servidor.

Con las imágenes ocurre lo contrario. Coil 3 activa la caché de memoria y de disco por defecto, así que la configuración de T5 ya es correcta y no hay que tocarla:

 1import android.app.Application
 2import coil3.ImageLoader
 3import coil3.PlatformContext
 4import coil3.SingletonImageLoader
 5import coil3.network.okhttp.OkHttpNetworkFetcherFactory
 6import coil3.request.crossfade
 7
 8// ─── AppDummyApplication.kt — sin cambios respecto a T5 ──────────────────────────────────────────
 9class AppDummyApplication : Application(), SingletonImageLoader.Factory {
10    lateinit var container: AppContainer
11
12    override fun onCreate() {
13        super.onCreate()
14        container = DefaultAppContainer(this)
15    }
16
17    override fun newImageLoader(context: PlatformContext): ImageLoader =
18        ImageLoader.Builder(context)
19            .components {
20                // Mismo OkHttpClient que Retrofit: un solo pool de conexiones, los
21                // mismos timeouts y la misma cabecera User-Agent (T5)
22                add(OkHttpNetworkFetcherFactory(callFactory = { container.okHttpClient }))
23            }
24            // Coil crea por defecto una caché de memoria (un porcentaje de la RAM
25            // disponible) y otra de disco. No hace falta configurarlas a mano.
26            .crossfade(true)
27            .build()
28}

Comprobación práctica. Con HttpLoggingInterceptor en Level.BODY, abre el listado dos veces seguidas y observa Logcat: las peticiones a openlibrary.org solo aparecen durante la sincronización, y las de covers.openlibrary.org desaparecen a partir de la segunda vez. Si ves peticiones de carátulas en cada desplazamiento de la lista, es que el ImageLoader no se está compartiendo. Ten en cuenta que Coil 3 dejó de respetar las cabeceras de caché HTTP por defecto; para las carátulas de Open Library es justo lo que interesa, porque un libro sin portada devuelve 404 y no conviene reintentar la descarga en cada recomposición.


10. El recorrido completo: qué ocurre al abrir la app#

El usuario abre AppDummy
       │
       ▼
AppDummyApplication.onCreate() → DefaultAppContainer(this)
       │
       ▼
MainActivity.setContent { AppNavigation() } → PantallaListado
       │
       ▼
LibrosViewModel creado (Factory desde el AppContainer)
       │
       ├──► uiState = stateIn(flatMapLatest(busqueda) → Room)
       │         │
       │         └─► Room emite la biblioteca guardada  ──► la lista aparece YA
       │             (aunque no haya cobertura)              LibrosUiState.Exito
       │
       ├──► hayConexion = stateIn(ObservadorConectividad.hayConexion)
       │         └─► si es false se muestra el banner y sincronizar() no llama a la red
       │
       └──► init { sincronizar(forzar = false) }
                │
                ▼
           _estaSincronizando = true
                │
                ▼
           repository.sincronizarBiblioteca(forzar = false)
                │
                ├─ localDataSource.obtenerCaducados(hoy - 7 días, 20)
                │       └─► SELECT ... WHERE isbn <> '' AND actualizado_en < :limite
                │
                └─ para cada libro caducado:
                        remoteDataSource.buscarPorIsbn(libro.isbn)   ── GET a
                        │                                   openlibrary.org/search.json
                        ▼
                        localDataSource.refrescarDesdeRed(id, datos, ahora)
                        │       └─► UPDATE titulo, autor, year, isbn, cover, actualizado_en
                        │           (es_favorito y leido intactos)
                        ▼
                        delay(1,1 s)   ← respeto del límite de peticiones
                │
                ▼
           Room detecta el cambio en la tabla
                │
                ▼
           el Flow reemite → uiState = LibrosUiState.Exito(lista actualizada)
                │
                ▼
           PantallaListado se recompone con los datos nuevos
                │
                ▼
           _estaSincronizando = false  +  Snackbar con el resumen

Si en cualquier punto falla la red, el camino se interrumpe pero la rama de lectura nunca se ve afectada: la lista que se pintó en el segundo paso sigue en pantalla.


Estructura de paquetes al final del Bloque 3#

com.ejemplo.appdummy/
├── AppDummyApplication.kt                    ← AppContainer + ImageLoader de Coil
├── MainActivity.kt
│
├── data/
│   ├── datasource/
│   │   ├── local/
│   │   │   ├── AppDatabase.kt                ← @Database(version = 2)
│   │   │   ├── Converters.kt
│   │   │   ├── LibrosDao.kt                  ← + obtenerCaducados, marcarComprobado
│   │   │   └── LocalDataSource.kt            ← + refrescarDesdeRed
│   │   └── remote/
│   │       ├── dto/
│   │       │   ├── BusquedaResponseDto.kt
│   │       │   ├── LibroDto.kt
│   │       │   └── LibroMapper.kt
│   │       ├── OpenLibrary.kt
│   │       ├── OpenLibraryApiService.kt
│   │       ├── RemoteDataSource.kt
│   │       └── RetrofitClient.kt
│   ├── di/
│   │   └── AppContainer.kt                   ← + observadorConectividad
│   ├── model/
│   │   └── Libro.kt                          ← + columna actualizado_en
│   └── repository/
│       ├── LibrosRepository.kt               ← + sincronizarBiblioteca()
│       └── ResultadoSincronizacion.kt        ← NUEVO en T6
│
├── navegacion/
│   ├── AppDummyBottomBar.kt
│   ├── AppNavigation.kt
│   ├── ItemsNavegacion.kt
│   └── Rutas.kt
│
├── screens/
│   ├── componentes/
│   │   ├── CaratulaLibro.kt
│   │   └── ItemLibro.kt
│   ├── detalle/
│   ├── favoritos/
│   ├── listado/
│   │   ├── LibrosEvento.kt
│   │   ├── LibrosUiState.kt
│   │   ├── LibrosViewModel.kt                ← + sincronizar() y flatMapLatest
│   │   └── PantallaListado.kt                ← + PullToRefreshBox y banner
│   └── nuevo/
│       ├── NuevoLibroUiState.kt
│       ├── NuevoLibroViewModel.kt
│       └── PantallaNuevoLibro.kt
│
└── utils/
    ├── Isbn.kt
    └── ObservadorConectividad.kt             ← NUEVO en T6

Fíjate en dónde han caído los cambios de este tema: casi todos en data/. La interfaz solo ha ganado un contenedor (PullToRefreshBox) y un aviso. Cuando una decisión de arquitectura está bien colocada, la interfaz apenas se da cuenta.


Desarrollo práctico guiado: AppDummy offline-first 💻#

Al terminar el T5, la aplicación ya sabe leer de Room y escribir desde Open Library, pero cada cosa por su lado. En este tema se unen bajo una única estrategia. Estos son los cambios que debes realizar sobre la versión v7 :

  • Añade la columna actualizado_en a la entidad Libro, incrementa a 2 la versión de @Database y declara el permiso ACCESS_NETWORK_STATE en el manifiesto. Desinstala la app del emulador antes de volver a ejecutarla para partir de una base de datos limpia.
  • Amplía LibrosDao con obtenerCaducados(), observarUltimaSincronizacion() y marcarComprobado(), y añade el parámetro actualizadoEn a actualizarDesdeRed() y a upsertConservandoFavorito(). Propaga esos métodos a LocalDataSource.
  • Implementa sincronizarBiblioteca() en LibrosRepository junto con ResultadoSincronizacion. Comprueba que no devuelve libros: si sientes la tentación de devolver la lista, repasa la sección 2.
  • Verifica en Logcat que entre dos peticiones consecutivas transcurre algo más de un segundo. Prueba después a quitar el delay() con quince libros en la biblioteca y observa la respuesta del servidor: es la forma más didáctica de ver un HTTP 429.
  • Sustituye el filtrado en memoria de PantallaListado por flatMapLatest sobre observarPorTitulo(). Con Level.BODY activo verás que buscar ya no genera ninguna petición de red: la búsqueda es local.
  • Crea ObservadorConectividad y expónlo desde el AppContainer. Comprueba con el modo avión que el banner aparece y desaparece sin reiniciar la app y sin que la lista se vacíe ni un instante.
  • Envuelve el contenido del listado en PullToRefreshBox. Prueba el gesto con la biblioteca vacía: si no se dispara, revisa que el estado vacío esté dentro del LazyColumn (sección 7).
  • Marca un libro como favorito y como leído, fuerza una sincronización y confirma con App Inspection de Android Studio que es_favorito y leido siguen valiendo 1 mientras actualizado_en ha cambiado. Es la comprobación central del tema.
  • Repite la prueba anterior sustituyendo actualizarDesdeRed() por dao.upsert(libro) y observa cómo se pierden los favoritos. Deshaz el cambio después: ahora ya sabes por qué existe upsertConservandoFavorito.
  • Da de alta un libro manualmente, sin usar la búsqueda, escribiendo solo un ISBN-13 válido y un título y un autor inventados. Sincroniza y comprueba que Open Library completa el año y la portada sin cambiar el id local /local/<uuid>.
  • Prueba la app entera en modo avión: abrir, buscar, marcar favoritos, entrar al detalle y volver. Todo debe funcionar salvo la sincronización, que debe avisar con un Snackbar sin bloquear nada.
  • Añade un test unitario en app/src/test que verifique el mensaje devuelto por mensajeDe() para los cuatro casos posibles de ResultadoSincronizacion. Es una función pura, no necesita emulador.

Ampliación opcional. Convierte la ruta NuevoLibro en data class NuevoLibro(val titulo: String? = null) para que el botón del estado vacío abra el formulario con el texto de búsqueda ya escrito. Es un buen repaso de los argumentos tipados de Navigation Compose (T3) y del uso de SavedStateHandle en el ViewModel.


Referencias#

Calendar  Última modificación: domingo, 6 de septiembre de 2026