Anexo 4. Referencia: Room avanzado, migraciones, testing y API Key segura

  • Tipo: Anexo de apoyo — material de consulta y profundización
  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • 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.

1. Almacenar la API Key de forma segura#

La API Key de TMDB nunca debe aparecer directamente en el código fuente ni subirse a un repositorio de control de versiones. El método estándar en Android es almacenarla en local.properties y acceder a ella a través de BuildConfig.

Paso 1: almacenar la clave en local.properties#

# local.properties — este archivo está en .gitignore por defecto
# NUNCA subir este archivo a git
sdk.dir=/Users/tu_usuario/Library/Android/sdk
TMDB_API_KEY=tu_clave_api_aqui_de_32_caracteres

local.properties está incluido en el .gitignore que Android Studio genera automáticamente en cada proyecto nuevo. Verificar que existe esta entrada antes de hacer el primer commit.

Paso 2: leer la clave en build.gradle.kts y exponerla como BuildConfig#

 1// build.gradle.kts (módulo app)
 2import java.util.Properties
 3
 4// Leer local.properties al compilar
 5val localProperties = Properties()
 6val localPropertiesFile = rootProject.file("local.properties")
 7if (localPropertiesFile.exists()) {
 8    localProperties.load(localPropertiesFile.inputStream())
 9}
10
11android {
12    defaultConfig {
13        // BuildConfig.TMDB_API_KEY estará disponible en el código Kotlin
14        // Si la clave no existe en local.properties, usa cadena vacía como fallback
15        buildConfigField(
16            "String",
17            "TMDB_API_KEY",
18            "\"${localProperties.getProperty("TMDB_API_KEY", "")}\""
19        )
20    }
21
22    // buildConfig debe estar habilitado explícitamente desde AGP 8.0
23    buildFeatures {
24        buildConfig = true
25    }
26}

Paso 3: usar BuildConfig.TMDB_API_KEY en el código#

 1// RemoteDataSource.kt
 2class RemoteDataSource(private val apiService: TmdbApiService) {
 3
 4    // La clave se obtiene de BuildConfig, que Gradle genera en tiempo de compilación
 5    // a partir de local.properties
 6    private val apiKey = BuildConfig.TMDB_API_KEY
 7
 8    suspend fun obtenerPopulares(pagina: Int = 1): List<Pelicula> {
 9        return apiService.obtenerPopulares(apiKey = apiKey, pagina = pagina).resultados
10    }
11}

Para proyectos en equipo: cada desarrollador debe tener su propio local.properties con su propia API Key. En proyectos de clase, el profesor puede compartir una API Key de desarrollo que todos usen (dado que el repositorio es privado), pero en proyectos reales cada entorno (desarrollo, staging, producción) usa claves distintas.


2. Migraciones de esquema en Room#

Cuando se modifica la estructura de la base de datos (añadir columnas, renombrar tablas, cambiar tipos), Room detecta que el esquema actual no coincide con el que espera y lanza una IllegalStateException. Para resolverlo sin perder los datos hay que proporcionar una migración.

Cuándo es necesaria una migración#

La versión en @Database(version = N) debe incrementarse cada vez que cambia el esquema. Room compara la versión almacenada en el archivo de BD con la versión declarada en el código y aplica las migraciones necesarias.

 1// Escenario: en la versión 1 la tabla "peliculas" no tenía la columna "fecha_estreno"
 2// En la versión 2 añadimos esa columna
 3
 4@Database(
 5    entities = [Pelicula::class],
 6    version = 2,           // incrementado de 1 a 2
 7    exportSchema = true    // imprescindible para rastrear el histórico de esquemas
 8)
 9abstract class AppDatabase : RoomDatabase() {
10    abstract fun peliculaDao(): PeliculaDao
11}

Escribir una migración manual#

 1// La migración se define como un objeto con el SQL necesario para ir de V1 a V2
 2val MIGRATION_1_2 = object : Migration(1, 2) {
 3    override fun migrate(database: SupportSQLiteDatabase) {
 4        // Añadir una columna nueva con valor por defecto
 5        // SQLite no permite añadir columnas NOT NULL sin valor por defecto
 6        database.execSQL(
 7            "ALTER TABLE peliculas ADD COLUMN fecha_estreno TEXT DEFAULT NULL"
 8        )
 9    }
10}
11
12val MIGRATION_2_3 = object : Migration(2, 3) {
13    override fun migrate(database: SupportSQLiteDatabase) {
14        // Crear una tabla nueva
15        database.execSQL("""
16            CREATE TABLE IF NOT EXISTS `listas_personalizadas` (
17                `lista_id` INTEGER NOT NULL,
18                `nombre` TEXT NOT NULL,
19                PRIMARY KEY(`lista_id`)
20            )
21        """)
22    }
23}
24
25// Registrar las migraciones al construir la base de datos
26Room.databaseBuilder(context, AppDatabase::class.java, "appflix_database")
27    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)   // Room aplica las necesarias en orden
28    .build()

fallbackToDestructiveMigration: para proyectos de aprendizaje#

En proyectos académicos donde los datos pueden perderse sin problema, fallbackToDestructiveMigration elimina y recrea las tablas automáticamente si detecta un cambio de esquema sin migración definida:

1Room.databaseBuilder(context, AppDatabase::class.java, "appflix_database")
2    .fallbackToDestructiveMigration(dropAllTables = true)   // solo para desarrollo
3    // En producción: .addMigrations(MIGRATION_1_2, ...)
4    .build()

Exportación de esquemas#

El plugin androidx.room exporta automáticamente el esquema de cada versión a /app/schemas/. Estos archivos JSON son el contrato que garantiza que las migraciones son correctas y deben incluirse en el control de versiones:

// app/schemas/com.ejemplo.appflix.data.local.AppDatabase/1.json
{
  "formatVersion": 1,
  "database": {
    "version": 1,
    "identityHash": "abc123...",
    "entities": [
      {
        "tableName": "peliculas",
        "createSql": "CREATE TABLE IF NOT EXISTS `peliculas` ..."
      }
    ]
  }
}

3. Room avanzado: consultas complejas y FlowExtensions#

Consultas con múltiples condiciones y parámetros#

 1@Dao
 2interface PeliculaDao {
 3
 4    // Filtrar por múltiples criterios opcionales
 5    // Los valores null se ignoran en la condición
 6    @Query("""
 7        SELECT * FROM peliculas
 8        WHERE (:generoFiltro IS NULL OR genero_ids LIKE '%' || :generoFiltro || '%')
 9          AND (:puntuacionMinima IS NULL OR puntuacion >= :puntuacionMinima)
10          AND (:soloFavoritas = 0 OR es_favorita = 1)
11        ORDER BY puntuacion DESC
12        LIMIT :limite
13    """)
14    fun buscarConFiltros(
15        generoFiltro: String? = null,
16        puntuacionMinima: Double? = null,
17        soloFavoritas: Boolean = false,
18        limite: Int = 50
19    ): Flow<List<Pelicula>>
20
21    // Contar registros — útil para badges de favoritos en la UI
22    @Query("SELECT COUNT(*) FROM peliculas WHERE es_favorita = 1")
23    fun contarFavoritas(): Flow<Int>
24
25    // Consulta con IN — obtener varias películas por sus ids
26    @Query("SELECT * FROM peliculas WHERE id IN (:ids)")
27    suspend fun obtenerPorIds(ids: List<Int>): List<Pelicula>
28
29    // Eliminar todos los registros — útil para invalidar la caché
30    @Query("DELETE FROM peliculas")
31    suspend fun eliminarTodas()
32
33    // Paginación manual con LIMIT y OFFSET
34    @Query("SELECT * FROM peliculas ORDER BY puntuacion DESC LIMIT :limite OFFSET :offset")
35    suspend fun obtenerPagina(limite: Int, offset: Int): List<Pelicula>
36}

Combinar múltiples Flows de Room con combine#

combine permite crear un Flow que emite cada vez que cualquiera de sus fuentes cambia, combinando los últimos valores de todas ellas:

 1class ListadoViewModel(private val repository: PeliculasRepository) : ViewModel() {
 2
 3    private val _filtros = MutableStateFlow(FiltrosBusqueda())
 4
 5    // uiState se recalcula automáticamente cuando cambian las películas O los filtros
 6    val uiState: StateFlow<PeliculasUiState> = combine(
 7        repository.observarPeliculas(),    // emite cuando Room cambia
 8        _filtros                           // emite cuando el usuario cambia filtros
 9    ) { peliculas, filtros ->
10        val filtradas = peliculas.filter { pelicula ->
11            (filtros.soloFavoritas.not() || pelicula.esFavorita) &&
12            (filtros.puntuacionMinima == null || pelicula.puntuacion >= filtros.puntuacionMinima) &&
13            (filtros.busqueda.isBlank() || pelicula.titulo.contains(filtros.busqueda, ignoreCase = true))
14        }
15        PeliculasUiState.Exito(filtradas)
16    }
17    .catch { emit(PeliculasUiState.Error(it.message ?: "Error")) }
18    .stateIn(
19        scope = viewModelScope,
20        started = SharingStarted.WhileSubscribed(5_000),
21        initialValue = PeliculasUiState.Cargando
22    )
23
24    fun actualizarFiltros(nuevosFiltros: FiltrosBusqueda) {
25        _filtros.value = nuevosFiltros
26    }
27}
28
29data class FiltrosBusqueda(
30    val busqueda: String = "",
31    val soloFavoritas: Boolean = false,
32    val puntuacionMinima: Double? = null
33)

4. Relación N:M con @Junction#

Para relaciones muchos-a-muchos (N:M), Room usa una tabla de unión (junction table) y la anotación @Junction en el POJO de relación. El ejemplo muestra actores y películas: una película tiene muchos actores, y un actor actúa en muchas películas.

 1// ─── Entidades ────────────────────────────────────────────────────────────
 2
 3@Entity(tableName = "actores")
 4data class Actor(
 5    @PrimaryKey val actorId: Int,
 6    val nombre: String,
 7    val nacionalidad: String = ""
 8)
 9
10// Tabla de unión — solo tiene las claves foráneas de ambas entidades
11@Entity(
12    tableName = "pelicula_actor",
13    primaryKeys = ["peliculaId", "actorId"],   // clave primaria compuesta
14    foreignKeys = [
15        ForeignKey(entity = Pelicula::class,
16            parentColumns = ["id"], childColumns = ["peliculaId"], onDelete = ForeignKey.CASCADE),
17        ForeignKey(entity = Actor::class,
18            parentColumns = ["actorId"], childColumns = ["actorId"], onDelete = ForeignKey.CASCADE)
19    ]
20)
21data class PeliculaActorCrossRef(
22    @ColumnInfo(index = true) val peliculaId: Int,
23    @ColumnInfo(index = true) val actorId: Int
24)
25
26// ─── POJOs de relación (NO son @Entity) ──────────────────────────────────
27
28// Película con todos sus actores
29data class PeliculaConActores(
30    @Embedded val pelicula: Pelicula,
31    @Relation(
32        parentColumn = "id",
33        entityColumn = "actorId",
34        associateBy = Junction(PeliculaActorCrossRef::class)
35    )
36    val actores: List<Actor>
37)
38
39// Actor con todas sus películas
40data class ActorConPeliculas(
41    @Embedded val actor: Actor,
42    @Relation(
43        parentColumn = "actorId",
44        entityColumn = "id",
45        associateBy = Junction(PeliculaActorCrossRef::class)
46    )
47    val peliculas: List<Pelicula>
48)
49
50// ─── DAO ─────────────────────────────────────────────────────────────────
51@Dao
52interface ActorDao {
53
54    @Transaction   // OBLIGATORIO con @Relation + @Junction
55    @Query("SELECT * FROM peliculas WHERE id = :peliculaId")
56    fun observarPeliculaConActores(peliculaId: Int): Flow<PeliculaConActores?>
57
58    @Transaction
59    @Query("SELECT * FROM actores WHERE actorId = :actorId")
60    fun observarActorConPeliculas(actorId: Int): Flow<ActorConPeliculas?>
61
62    @Insert(onConflict = OnConflictStrategy.REPLACE)
63    suspend fun insertarActor(actor: Actor)
64
65    @Insert(onConflict = OnConflictStrategy.REPLACE)
66    suspend fun insertarRelacion(crossRef: PeliculaActorCrossRef)
67
68    @Delete
69    suspend fun eliminarRelacion(crossRef: PeliculaActorCrossRef)
70}

5. Paginación con Paging 3#

Para catálogos con miles de elementos, cargar toda la lista en memoria no es viable. La biblioteca Paging 3 de Jetpack gestiona la carga de datos por páginas de forma transparente:

 1// Solo se muestra la estructura básica — Paging 3 es un tema avanzado
 2
 3// Dependencias adicionales (no incluidas en la configuración base del Bloque 3)
 4// implementation("androidx.paging:paging-runtime:3.3.0")
 5// implementation("androidx.paging:paging-compose:3.3.0")
 6// implementation("androidx.room:room-paging:2.7.1")
 7
 8// En el DAO — Room genera automáticamente el PagingSource
 9@Query("SELECT * FROM peliculas ORDER BY puntuacion DESC")
10fun obtenerPaginado(): PagingSource<Int, Pelicula>
11
12// En el Repository
13fun obtenerPeliculasPaginadas(): Flow<PagingData<Pelicula>> {
14    return Pager(
15        config = PagingConfig(pageSize = 20, prefetchDistance = 5),
16        pagingSourceFactory = { localDataSource.dao.obtenerPaginado() }
17    ).flow
18}
19
20// En el Composable — LazyPagingItems gestiona la carga automáticamente
21@Composable
22fun PantallaListadoPaginada(viewModel: ViewModel) {
23    val peliculas = viewModel.peliculasPaginadas.collectAsLazyPagingItems()
24
25    LazyColumn {
26        items(count = peliculas.itemCount, key = peliculas.itemKey { it.id }) { index ->
27            peliculas[index]?.let { pelicula -> TarjetaPelicula(pelicula) }
28        }
29    }
30}

6. Testing avanzado: Repository con Fake DataSources#

Para testear el Repository sin bases de datos reales ni peticiones de red, se usan fakes: implementaciones simplificadas que simulan el comportamiento real.

  1// Fake de LocalDataSource para tests
  2class FakeLocalDataSource : LocalDataSource(
  3    // Pasar un DAO vacío — los métodos se sobreescriben abajo
  4    dao = object : PeliculaDao {
  5        override fun observarTodas() = flowOf(emptyList<Pelicula>())
  6        override fun observarFavoritas() = flowOf(emptyList<Pelicula>())
  7        override fun observarPorTitulo(busqueda: String) = flowOf(emptyList<Pelicula>())
  8        override fun observarPorId(id: Int) = flowOf(null)
  9        override suspend fun obtenerPorId(id: Int) = null
 10        override suspend fun insertarIgnorando(peliculas: List<Pelicula>) = emptyList<Long>()
 11        override suspend fun insertarReemplazando(pelicula: Pelicula) {}
 12        override suspend fun upsert(pelicula: Pelicula) {}
 13        override suspend fun actualizar(pelicula: Pelicula) {}
 14        override suspend fun eliminar(pelicula: Pelicula) {}
 15        override suspend fun actualizarDesdeRed(id: Int, titulo: String, genero: String,
 16            puntuacion: Double, sinopsis: String, posterUrl: String?) {}
 17        override suspend fun toggleFavorita(id: Int) {}
 18        override suspend fun upsertConservandoFavorita(peliculas: List<Pelicula>) {}
 19    }
 20) {
 21    // Estado interno del fake — simula la base de datos en memoria
 22    private val peliculasEnMemoria = mutableListOf<Pelicula>()
 23    private val _flow = MutableStateFlow<List<Pelicula>>(emptyList())
 24
 25    override fun observarTodas(): Flow<List<Pelicula>> = _flow.asStateFlow()
 26
 27    override suspend fun upsertConservandoFavorita(peliculas: List<Pelicula>) {
 28        peliculas.forEach { nueva ->
 29            val indice = peliculasEnMemoria.indexOfFirst { it.id == nueva.id }
 30            if (indice >= 0) {
 31                // Conservar esFavorita al actualizar
 32                peliculasEnMemoria[indice] = nueva.copy(
 33                    esFavorita = peliculasEnMemoria[indice].esFavorita
 34                )
 35            } else {
 36                peliculasEnMemoria.add(nueva)
 37            }
 38        }
 39        _flow.value = peliculasEnMemoria.toList()
 40    }
 41
 42    override suspend fun toggleFavorita(id: Int) {
 43        val indice = peliculasEnMemoria.indexOfFirst { it.id == id }
 44        if (indice >= 0) {
 45            peliculasEnMemoria[indice] = peliculasEnMemoria[indice].copy(
 46                esFavorita = !peliculasEnMemoria[indice].esFavorita
 47            )
 48            _flow.value = peliculasEnMemoria.toList()
 49        }
 50    }
 51}
 52
 53// Fake de RemoteDataSource para tests
 54class FakeRemoteDataSource(
 55    private val peliculasRemotas: List<Pelicula> = listOf(
 56        Pelicula(1, "Dune: Parte 2", "", 8.5, "Sinopsis"),
 57        Pelicula(2, "Oppenheimer", "", 8.9, "Sinopsis")
 58    ),
 59    private val debefallar: Boolean = false
 60) : RemoteDataSource(apiService = TODO("No se usa en tests")) {
 61
 62    override suspend fun obtenerPopulares(pagina: Int): List<Pelicula> {
 63        if (debefallar) throw IOException("Sin conexión simulada")
 64        return peliculasRemotas
 65    }
 66}
 67
 68// Test del Repository usando los fakes
 69@OptIn(ExperimentalCoroutinesApi::class)
 70class PeliculasRepositoryTest {
 71
 72    @get:Rule
 73    val mainDispatcherRule = MainDispatcherRule()
 74
 75    private lateinit var fakeLocal: FakeLocalDataSource
 76    private lateinit var fakeRemote: FakeRemoteDataSource
 77    private lateinit var repository: PeliculasRepository
 78
 79    @Before
 80    fun setup() {
 81        fakeLocal = FakeLocalDataSource()
 82        fakeRemote = FakeRemoteDataSource()
 83        repository = PeliculasRepository(fakeLocal, fakeRemote)
 84    }
 85
 86    @Test
 87    fun `sincronizar guarda las peliculas remotas en local`() = runTest {
 88        repository.sincronizar()
 89        val peliculas = repository.observarPeliculas().first()
 90        assertEquals(2, peliculas.size)
 91    }
 92
 93    @Test
 94    fun `sincronizar falla silenciosamente sin destruir datos locales`() = runTest {
 95        // 1. Cargar datos previos en local
 96        fakeLocal.upsertConservandoFavorita(listOf(
 97            Pelicula(99, "Película existente", "", 7.0, "")
 98        ))
 99
100        // 2. La red falla
101        val repositorioConFalloDeRed = PeliculasRepository(
102            localDataSource = fakeLocal,
103            remoteDataSource = FakeRemoteDataSource(debefallar = true)
104        )
105
106        // 3. sincronizar() lanza IOException pero el dato local sigue ahí
107        try {
108            repositorioConFalloDeRed.sincronizar()
109        } catch (e: IOException) {
110            // esperado
111        }
112
113        val peliculas = repository.observarPeliculas().first()
114        assertEquals(1, peliculas.size)
115        assertEquals("Película existente", peliculas.first().titulo)
116    }
117
118    @Test
119    fun `toggleFavorita invierte el estado de favorita`() = runTest {
120        // Preparar un dato inicial
121        fakeLocal.upsertConservandoFavorita(listOf(
122            Pelicula(1, "Test", "", 7.0, "")
123        ))
124
125        repository.toggleFavorita(1)
126
127        val peliculas = repository.observarPeliculas().first()
128        assertTrue(peliculas.first().esFavorita)
129    }
130}

7. Tabla de referencia rápida: versiones del Bloque 3#

 1// build.gradle.kts (nivel de proyecto)
 2plugins {
 3    id("com.android.application")         version "8.10.0"  apply false
 4    id("org.jetbrains.kotlin.android")    version "2.1.20"  apply false
 5    id("com.google.devtools.ksp")         version "2.1.20-1.0.31" apply false
 6    id("androidx.room")                   version "2.7.1"   apply false
 7}
 8
 9// build.gradle.kts (módulo app)
10dependencies {
11    // ── Bloque 2 (ya incluidas) ───────────────────────────────────────────────
12    val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
13    implementation(composeBom)
14    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
15    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2")
16    implementation("androidx.navigation:navigation-compose:2.9.7")
17
18    // ── Bloque 3 — Room ───────────────────────────────────────────────────────
19    implementation("androidx.room:room-runtime:2.7.1")
20    implementation("androidx.room:room-ktx:2.7.1")
21    ksp("androidx.room:room-compiler:2.7.1")
22    testImplementation("androidx.room:room-testing:2.7.1")
23
24    // ── Bloque 3 — Retrofit2 + OkHttp ────────────────────────────────────────
25    implementation("com.squareup.retrofit2:retrofit:2.11.0")
26    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
27    implementation("com.squareup.okhttp3:okhttp:4.12.0")
28    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
29    implementation("com.google.code.gson:gson:2.11.0")
30
31    // ── Bloque 3 — Coil (imágenes) ────────────────────────────────────────────
32    implementation("io.coil-kt.coil3:coil-compose:3.2.0")
33    implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
34
35    // ── Testing ───────────────────────────────────────────────────────────────
36    testImplementation("junit:junit:4.13.2")
37    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
38    androidTestImplementation("androidx.test.ext:junit:1.2.1")
39    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
40    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
41    debugImplementation("androidx.compose.ui:ui-test-manifest")
42}
Librería Versión Notas
KSP 2.1.20-1.0.31 Misma versión de Kotlin en el prefijo
Room (todos los artefactos) 2.7.1 Requiere KSP (no kapt) con Kotlin 2.x
Retrofit 2.11.0 Incluye OkHttp transitivamente
OkHttp 4.12.0 Especificar explícitamente para fijar versión
Gson 2.11.0 Serialización JSON para Retrofit
Coil 3.2.0 Grupo io.coil-kt.coil3 (con el 3)

8. Checklist de verificación: proyecto completo#

Antes de la demostración del H6, verificar que se cumplen todos los puntos:

Configuración del proyecto:

  • local.properties tiene TMDB_API_KEY y no está en git
  • buildConfigField configurado en build.gradle.kts
  • KSP en lugar de kapt para Room
  • Plugin androidx.room con schemaDirectory configurado
  • Permiso INTERNET en AndroidManifest.xml
  • AppFlixApplication declarada en AndroidManifest.xml

Capa de datos:

  • Pelicula.kt tiene @Entity + @SerializedName + esFavorita = false por defecto
  • PeliculaDao.kt usa Flow<> para observación y suspend para escritura
  • upsertConservandoFavorita está anotado con @Transaction
  • AppDatabase exporta el esquema (exportSchema = true)
  • AppContainer inicializa las dependencias con by lazy en orden correcto
  • OkHttpClient es único y compartido por Retrofit y Coil

Arquitectura MVVM:

  • PeliculasRepository usa localDataSource para observar y remoteDataSource para sincronizar
  • sincronizar() captura IOException y HttpException sin propagar a la UI
  • PeliculasViewModel usa stateIn(SharingStarted.WhileSubscribed(5_000))
  • El ViewModel obtiene el repositorio a través del AppContainer, no directamente

UI:

  • collectAsStateWithLifecycle() en lugar de collectAsState()
  • PullToRefreshBox conectado a estaSincronizando y sincronizar()
  • AsyncImage con placeholder y error configurados
  • Navegación con rutas @Serializable y callbacks (no NavController directo)

Comportamiento:

  • Los favoritos se conservan entre reinicios de la app
  • upsertConservandoFavorita no sobreescribe esFavorita al sincronizar
  • La app funciona offline mostrando los datos cacheados
  • No hay crash al desactivar el WiFi con la app en uso

Referencias del Anexo#

Calendar  Última modificación: viernes, 26 de junio de 2026