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

  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • Duración aproximada: 10 horas
  • 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 incorporó en T4 y T5. Solo hay que comprobar una cosa en el proyecto y añadir un permiso.

 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, 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 exige 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-A4 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 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 app 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 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 desconoce)
    4. Anotar la fecha de la comprobación
    5. Esperar 1,1 s 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}

¿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 de 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    suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) =
29        dao.upsertConservandoFavorito(libros, instante)
30}

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    private val _eventos = MutableSharedFlow<LibrosEvento>()
 34    val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
 35
 36    // ─── Conectividad: la UI la usa para avisar, NO para elegir la fuente ───
 37    val hayConexion: StateFlow<Boolean> = observadorConectividad.hayConexion
 38        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
 39
 40    // ─── Instante de la última sincronización, observado desde Room ─────────
 41    val ultimaSincronizacion: StateFlow<Long?> = repository.observarUltimaSincronizacion()
 42        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
 43
 44    // ─── Estado principal: la búsqueda se resuelve en SQL ───────────────────
 45    // flatMapLatest sustituye el Flow anterior por uno nuevo cada vez que cambia el
 46    // texto: al escribir se cancela la consulta previa y se abre otra sobre Room.
 47    // El resultado sigue siendo reactivo (si cambia la tabla, vuelve a emitir).
 48    val uiState: StateFlow<LibrosUiState> = _busqueda
 49        .debounce(300.milliseconds)      // no consultar en cada pulsación
 50        .distinctUntilChanged()
 51        .flatMapLatest { texto ->
 52            if (texto.isBlank()) repository.observarLibros()
 53            else repository.observarPorTitulo(texto)
 54        }
 55        .map<List<Libro>, LibrosUiState> { LibrosUiState.Exito(it) }
 56        .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
 57        .stateIn(
 58            scope = viewModelScope,
 59            started = SharingStarted.WhileSubscribed(5_000),
 60            initialValue = LibrosUiState.Cargando
 61        )
 62
 63    init {
 64        // Sincronización silenciosa al crear el ViewModel: solo los caducados
 65        sincronizar(forzar = false)
 66    }
 67
 68    fun actualizarBusqueda(texto: String) { _busqueda.value = texto }
 69
 70    fun toggleFavorito(libro: Libro) {
 71        viewModelScope.launch {
 72            repository.toggleFavorito(libro.id)
 73            // No hay que refrescar nada: el Flow de Room emite la lista nueva
 74            _eventos.emit(
 75                LibrosEvento.MostrarSnackbar(
 76                    if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
 77                    else "\"${libro.titulo}\" añadido a favoritos"
 78                )
 79            )
 80        }
 81    }
 82
 83    // ─── Sincronización ─────────────────────────────────────────────────────
 84
 85    fun sincronizar(forzar: Boolean = true) {
 86        // Guarda de reentrada: evita lanzar dos sincronizaciones simultáneas si el
 87        // usuario arrastra la lista mientras ya se está sincronizando
 88        if (_estaSincronizando.value) return
 89
 90        viewModelScope.launch {
 91            if (!hayConexion.value) {
 92                _eventos.emit(
 93                    LibrosEvento.MostrarSnackbar("Sin conexión: se muestran los datos guardados.")
 94                )
 95                return@launch
 96            }
 97
 98            _estaSincronizando.value = true
 99            try {
100                val resultado = repository.sincronizarBiblioteca(forzar)
101                // La sincronización automática del init solo avisa si ha hecho algo
102                if (forzar || resultado.comprobados > 0 || resultado.haFallado) {
103                    _eventos.emit(LibrosEvento.MostrarSnackbar(mensajeDe(resultado)))
104                }
105            } finally {
106                // finally garantiza que el indicador se apaga aunque haya excepción
107                _estaSincronizando.value = false
108            }
109        }
110    }
111
112    private fun mensajeDe(resultado: ResultadoSincronizacion): String = when {
113        resultado.haFallado -> resultado.error ?: "No se ha podido sincronizar."
114        resultado.comprobados == 0 -> "La biblioteca ya está actualizada."
115        resultado.actualizados == 0 -> "Comprobados ${resultado.comprobados} libros, sin cambios."
116        else -> "Actualizados ${resultado.actualizados} de ${resultado.comprobados} libros."
117    }
118
119    companion object {
120        val Factory: ViewModelProvider.Factory = viewModelFactory {
121            initializer {
122                val app = checkNotNull(
123                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
124                ) as AppDummyApplication
125                LibrosViewModel(
126                    repository = app.container.librosRepository,
127                    observadorConectividad = app.container.observadorConectividad
128                )
129            }
130        }
131    }
132}

¿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.items
  6import androidx.compose.material.icons.Icons
  7import androidx.compose.material.icons.filled.Add
  8import androidx.compose.material.icons.filled.Clear
  9import androidx.compose.material.icons.filled.CloudOff
 10import androidx.compose.material.icons.filled.MenuBook
 11import androidx.compose.material.icons.filled.Refresh
 12import androidx.compose.material.icons.filled.Search
 13import androidx.compose.material3.*
 14import androidx.compose.material3.pulltorefresh.PullToRefreshBox
 15import androidx.compose.runtime.*
 16import androidx.compose.ui.Alignment
 17import androidx.compose.ui.Modifier
 18import androidx.compose.ui.unit.dp
 19import androidx.lifecycle.compose.collectAsStateWithLifecycle
 20import androidx.lifecycle.viewmodel.compose.viewModel
 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 = {},
 28    onNavegaANuevoLibro: () -> Unit = {}
 29) {
 30    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 31    val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
 32    val estaSincronizando by viewModel.estaSincronizando.collectAsStateWithLifecycle()
 33    val hayConexion by viewModel.hayConexion.collectAsStateWithLifecycle()
 34    val ultimaSincronizacion by viewModel.ultimaSincronizacion.collectAsStateWithLifecycle()
 35
 36    val snackbarHostState = remember { SnackbarHostState() }
 37
 38    // Consumo de los eventos de un solo uso emitidos por el ViewModel.
 39    // LaunchedEffect(Unit): la corrutina se lanza una vez y vive mientras el
 40    // composable permanezca en la composición.
 41    LaunchedEffect(Unit) {
 42        viewModel.eventos.collect { evento ->
 43            when (evento) {
 44                is LibrosEvento.MostrarSnackbar ->
 45                    snackbarHostState.showSnackbar(evento.mensaje)
 46            }
 47        }
 48    }
 49
 50    Scaffold(
 51        topBar = {
 52            TopAppBar(
 53                title = {
 54                    Column {
 55                        Text("Mi biblioteca")
 56                        Text(
 57                            text = textoUltimaSincronizacion(ultimaSincronizacion),
 58                            style = MaterialTheme.typography.labelSmall,
 59                            color = MaterialTheme.colorScheme.onSurfaceVariant
 60                        )
 61                    }
 62                },
 63                actions = {
 64                    IconButton(
 65                        onClick = { viewModel.sincronizar() },
 66                        enabled = !estaSincronizando
 67                    ) {
 68                        Icon(Icons.Default.Refresh, contentDescription = "Sincronizar")
 69                    }
 70                }
 71            )
 72        },
 73        floatingActionButton = {
 74            FloatingActionButton(onClick = onNavegaANuevoLibro) {
 75                Icon(Icons.Default.Add, contentDescription = "Añadir libro")
 76            }
 77        },
 78        snackbarHost = { SnackbarHost(snackbarHostState) }
 79    ) { paddingValues ->
 80        Column(modifier = Modifier.padding(paddingValues)) {
 81
 82            // ─── Aviso de falta de conexión ─────────────────────────────────
 83            AnimatedVisibility(visible = !hayConexion) { BannerSinConexion() }
 84
 85            // ─── Búsqueda (el filtrado lo resuelve Room) ────────────────────
 86            OutlinedTextField(
 87                value = busqueda,
 88                onValueChange = viewModel::actualizarBusqueda,
 89                modifier = Modifier
 90                    .fillMaxWidth()
 91                    .padding(horizontal = 16.dp, vertical = 8.dp),
 92                placeholder = { Text("Buscar en mi biblioteca...") },
 93                leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
 94                trailingIcon = {
 95                    AnimatedVisibility(visible = busqueda.isNotEmpty()) {
 96                        IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
 97                            Icon(Icons.Default.Clear, contentDescription = "Borrar")
 98                        }
 99                    }
100                },
101                singleLine = true
102            )
103
104            // ─── Contenido con pull-to-refresh ──────────────────────────────
105            PullToRefreshBox(
106                isRefreshing = estaSincronizando,
107                onRefresh = { viewModel.sincronizar(forzar = true) },
108                modifier = Modifier.weight(1f)
109            ) {
110                when (val estado = uiState) {
111
112                    is LibrosUiState.Cargando ->
113                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
114                            CircularProgressIndicator()
115                        }
116
117                    is LibrosUiState.Error ->
118                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
119                            Text(estado.mensaje)
120                        }
121
122                    is LibrosUiState.Exito ->
123                        // IMPORTANTE: el contenido de PullToRefreshBox debe ser
124                        // desplazable para que el gesto se detecte. Por eso el
125                        // estado vacío también va dentro del LazyColumn.
126                        LazyColumn(modifier = Modifier.fillMaxSize()) {
127                            if (estado.libros.isEmpty()) {
128                                item {
129                                    EstadoVacio(
130                                        busqueda = busqueda,
131                                        onAnyadir = onNavegaANuevoLibro
132                                    )
133                                }
134                            } else {
135                                items(estado.libros, key = { it.id }) { libro ->
136                                    ItemLibro(
137                                        libro = libro,
138                                        onClickItem = { onNavegaADetalle(libro.id) },
139                                        onToggleFavorito = { viewModel.toggleFavorito(libro) }
140                                    )
141                                }
142                            }
143                        }
144                }
145            }
146        }
147    }
148}
149
150// Texto legible de la última sincronización. DateUtils.getRelativeTimeSpanString
151// devuelve cadenas como "hace 3 horas", ya traducidas por el sistema al idioma
152// del dispositivo, sin necesidad de formateadores ni de la API de java.time.
153private fun textoUltimaSincronizacion(instante: Long?): String =
154    if (instante == null || instante == 0L) "Sin sincronizar"
155    else "Actualizado ${DateUtils.getRelativeTimeSpanString(instante)}"
156
157@Composable
158private fun BannerSinConexion() {
159    Surface(
160        color = MaterialTheme.colorScheme.errorContainer,
161        modifier = Modifier.fillMaxWidth()
162    ) {
163        Row(
164            verticalAlignment = Alignment.CenterVertically,
165            horizontalArrangement = Arrangement.spacedBy(8.dp),
166            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
167        ) {
168            Icon(
169                imageVector = Icons.Default.CloudOff,
170                contentDescription = null,
171                tint = MaterialTheme.colorScheme.onErrorContainer
172            )
173            Text(
174                text = "Sin conexión. Se muestran los datos guardados.",
175                style = MaterialTheme.typography.bodySmall,
176                color = MaterialTheme.colorScheme.onErrorContainer
177            )
178        }
179    }
180}
181
182@Composable
183private fun EstadoVacio(busqueda: String, onAnyadir: () -> Unit) {
184    Column(
185        horizontalAlignment = Alignment.CenterHorizontally,
186        modifier = Modifier
187            .fillMaxWidth()
188            .padding(32.dp)
189    ) {
190        Icon(
191            imageVector = Icons.Default.MenuBook,
192            contentDescription = null,
193            modifier = Modifier.size(64.dp),
194            tint = MaterialTheme.colorScheme.onSurfaceVariant
195        )
196        Spacer(Modifier.height(16.dp))
197        Text(
198            text = if (busqueda.isBlank()) "Tu biblioteca está vacía"
199                   else "Ningún libro coincide con \"$busqueda\"",
200            style = MaterialTheme.typography.titleMedium
201        )
202        Spacer(Modifier.height(8.dp))
203        Text(
204            text = "Puedes buscarlo en Open Library y añadirlo a tu biblioteca.",
205            style = MaterialTheme.typography.bodySmall,
206            color = MaterialTheme.colorScheme.onSurfaceVariant
207        )
208        Spacer(Modifier.height(16.dp))
209        Button(onClick = onAnyadir) { Text("Añadir libro") }
210    }
211}

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 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
11    lateinit var container: AppContainer
12
13    override fun onCreate() {
14        super.onCreate()
15        container = DefaultAppContainer(this)
16    }
17
18    override fun newImageLoader(context: PlatformContext): ImageLoader =
19        ImageLoader.Builder(context)
20            .components {
21                // Mismo OkHttpClient que Retrofit: un solo pool de conexiones, los
22                // mismos timeouts y la misma cabecera User-Agent (T5)
23                add(OkHttpNetworkFetcherFactory(callFactory = { container.okHttpClient }))
24            }
25            // Coil crea por defecto una caché de memoria (un porcentaje de la RAM
26            // disponible) y otra de disco. No hace falta configurarlas a mano.
27            .crossfade(true)
28            .build()
29}

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 entera.


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

Al terminar 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: miércoles, 12 de agosto de 2026