P1. Práctica guiada: login contra una API REST
- Tipo: Práctica guiada (proyecto nuevo, desde cero).
- Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2.
- Duración aproximada: 6 horas.
- RA2 — Desarrolla aplicaciones para dispositivos móviles 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. |
| RA2-j | Se han establecido los permisos requeridos para el funcionamiento de las aplicaciones. |
T3 (Navigation Compose con rutas serializables), T5 (Retrofit2, OkHttp y DTOs) y el apartado de conectividad de T6.
1. Objetivo de la práctica#
Hasta ahora hemos consumido una API pública que no exige autenticación (Open Library). En el mundo real, la inmensa mayoría de las APIs REST protegen sus recursos: antes de poder leer o escribir datos hay que identificarse y obtener una credencial temporal —normalmente un token— que se envía en cada petición posterior.
En esta práctica se construirá, desde un proyecto vacío, una aplicación mínima que resuelve el ciclo completo de una sesión:
- Un formulario de login que envía usuario y contraseña a la API Coffee.
- Control de errores: credenciales incorrectas, datos incompletos, error del servidor y ausencia de red.
- Detección del estado de la conexión antes y durante el intento de login.
- Una pantalla que muestra el token recibido y su fecha de caducidad.
- Persistencia del token con DataStore Preferences, de forma que al cerrar y volver a abrir la aplicación la sesión siga activa.
- Comprobación de la caducidad del token dentro de la propia aplicación.
- Un botón de logout que destruye la sesión y devuelve al usuario al formulario.
Los datos de acceso de prueba son:
| Campo | Valor |
|---|---|
usuario |
alumno |
password |
alumno |
Por qué una app nueva y no seguir con AppDummy. El objetivo de esta práctica es aislar el mecanismo de autenticación. Al partir de cero, así vuelven a repasar todas las capas (datasource → repository → viewModel → UI) sin el ruido de una base de datos Room ya montada, y obtiene un proyecto pequeño y reutilizable como plantilla de login para futuros trabajos.
2. La API Coffee: el endpoint de login#
La API que se utilizará está publicada en: API Coffee
Ofrece los siguientes recursos:
| Método | Ruta | Autenticación | Descripción |
|---|---|---|---|
POST |
/login |
No | Devuelve un token JWT |
GET |
/coffee |
Bearer token | Listado de cafés |
GET |
/coffee/{id} |
Bearer token | Un café concreto |
GET |
/comments/{idCoffee} |
Bearer token | Comentarios de un café |
POST |
/comments |
Bearer token | Crear un comentario |
2.1. Petición y respuesta#
El endpoint de login espera un cuerpo JSON con dos campos, usuario y password:
{
"usuario": "alumno",
"password": "alumno"
}Y responde, en caso de éxito (200 OK), con un objeto que contiene el token:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c3VhcmlvIjoiYWx1bW5vIiwiaWF0IjoxNzU1NjAwMDAwLCJleHAiOjE3NTU2MDM2MDB9.firma"
}Los códigos de error documentados son:
| Código | Significado |
|---|---|
400 |
Faltan campos obligatorios en la petición |
401 |
Credenciales incorrectas, o token inválido/caducado |
403 |
No se ha proporcionado token en un endpoint protegido |
Verifica siempre la forma real de la respuesta. Antes de escribir el DTO, lanza la petición con el
HttpLoggingInterceptoren nivelBODY(apartado 6) o con una herramienta como Postman, y comprueba el nombre exacto de los campos. Un DTO cuyos nombres no coinciden con el JSON no provoca un error de compilación: Gson simplemente deja esas propiedades anull, y el fallo aparece mucho más tarde y en un sitio poco evidente.
2.2. Qué es un token JWT#
El token devuelto es un JWT (JSON Web Token). Es una cadena de texto formada por tres partes separadas por puntos:
cabecera . carga_útil . firma
eyJhbGci... . eyJ1c3Vh... . SflKxwRJ...- La cabecera (header) indica el algoritmo de firma.
- La carga útil (payload) contiene los datos de la sesión (claims): el usuario, el instante de emisión
iaty el instante de caducidadexp, ambos en segundos desde el 1 de enero de 1970 (época Unix). - La firma permite al servidor comprobar que el token no ha sido manipulado.
Las dos primeras partes están codificadas en Base64 URL-safe, no cifradas: cualquiera puede leerlas. Esto es precisamente lo que permitirá comprobar la caducidad en el cliente sin llamar al servidor (apartado 9).
Base64 no es cifrado. Codificar en Base64 solo transforma bytes en caracteres ASCII seguros para viajar por HTTP. Nunca se deben incluir datos sensibles en el payload de un JWT, porque son legibles por cualquiera que intercepte el token.
3. Arquitectura de la aplicación#
Se mantiene el flujo de capas que se ha utilizado en todo el módulo:
UI ↔ ViewModel ↔ Repository ↔ DataSources ↔ FrameworksAplicado a esta práctica:
| Capa | Componente | Responsabilidad |
|---|---|---|
| Frameworks | Retrofit / OkHttp | Transporte HTTP |
| Frameworks | DataStore Preferences |
Almacenamiento clave-valor asíncrono |
| DataSource | RemoteDataSource |
Llamar a /login y traducir códigos HTTP a resultados de dominio |
| DataSource | SesionLocalDataSource |
Guardar, leer y borrar el token |
| Repository | AutenticacionRepository |
Orquestar ambas fuentes y decidir si hay sesión válida |
| ViewModel | LoginViewModel, TokenViewModel |
Exponer estado de UI y ejecutar acciones |
| UI | PantallaLogin, PantallaToken |
Composables sin lógica de negocio |
Como en T5 y T6, no se utilizó ninguna librería de inyección de dependencias: las instancias se crean una sola vez en un
AppContainerque vive en la claseApplication.
4. Paso 1: crear el proyecto#
En Android Studio, File ▸ New ▸ New Project ▸ Empty Activity (plantilla de Compose), con los siguientes datos:
| Campo | Valor |
|---|---|
| Name | ExampleLogin |
| Package name | com.ejemplo.examplelogin |
| Minimum SDK | API 24 (Android 7.0) |
| Build configuration language | Kotlin DSL (build.gradle.kts) |
4.1. Dependencias#
Añade al catálogo de versiones gradle/libs.versions.toml:
1[versions]
2# ── DataStore ────────────────────────────────────────
3datastore = "1.2.1"
4# ── Red ──────────────────────────────────────────────
5retrofit = "3.0.0"
6okhttp = "5.5.0"
7gson = "2.14.0"
8# ── Navegación y serialización ───────────────────────
9navigationCompose = "2.9.8"
10kotlinxSerializationJson = "1.11.0"
11kotlin = "2.4.10" # ya existe en el catálogo generado por Android Studio
12# ── Lifecycle / ViewModel ────────────────────────────
13lifecycle = "2.11.0"
14
15[libraries]
16androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
17
18retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
19retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
20okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
21okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
22gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
23
24androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
25kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
26
27androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
28androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
29
30[plugins]
31kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }Y en app/build.gradle.kts:
1plugins {
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.compose)
4 alias(libs.plugins.kotlin.serialization) // ← necesario para las rutas de Navigation
5}
6
7android {
8 // ...
9 buildFeatures {
10 compose = true
11 buildConfig = true // ← habilita BuildConfig.DEBUG
12 }
13}
14
15dependencies {
16 // DataStore Preferences
17 implementation(libs.androidx.datastore.preferences)
18
19 // Red
20 implementation(libs.retrofit)
21 implementation(libs.retrofit.converter.gson)
22 implementation(libs.okhttp)
23 implementation(libs.okhttp.logging.interceptor)
24 implementation(libs.gson)
25
26 // Navegación con rutas type-safe
27 implementation(libs.androidx.navigation.compose)
28 implementation(libs.kotlinx.serialization.json)
29
30 // ViewModel y estado consciente del ciclo de vida
31 implementation(libs.androidx.lifecycle.viewmodel.compose)
32 implementation(libs.androidx.lifecycle.runtime.compose)
33}Versiones. Las versiones de Retrofit, OkHttp y Gson son las mismas que se utilizaron en T5, de modo que el catálogo es compatible con el proyecto AppDummy. Comprueba siempre en el panel de Gradle si Android Studio propone una versión superior antes de empezar.
El artefacto
datastore-preferencesarrastra las corrutinas de Kotlin, de modo que no hay que declararlas por separado.
4.2. Permisos#
En app/src/main/AndroidManifest.xml, dentro de <manifest> y fuera de <application>:
1<uses-permission android:name="android.permission.INTERNET" />
2<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />Estos dos permisos son de nivel normal: se conceden en la instalación y no requieren solicitud en tiempo de ejecución, a diferencia de los permisos peligrosos vistos anteriormente.
5. Paso 2: el modelo, los DTOs y el servicio#
5.1. DTOs de la petición y la respuesta#
1package com.ejemplo.examplelogin.data.datasource.remote.dto
2
3/**
4 * Cuerpo de la petición POST /login.
5 * Los nombres de las propiedades DEBEN coincidir con los del JSON que espera la API.
6 */
7// ─── data/datasource/remote/dto/LoginRequestDto.kt ───────────────────────────────────────────────
8data class LoginRequestDto(
9 val usuario: String,
10 val password: String
11) 1package com.ejemplo.examplelogin.data.datasource.remote.dto
2
3/**
4 * Respuesta de POST /login.
5 * El token se declara nullable porque, si la API cambiase el nombre del campo,
6 * Gson lo dejaría a null en lugar de lanzar una excepción.
7 */
8// ─── data/datasource/remote/dto/LoginResponseDto.kt ──────────────────────────────────────────────
9data class LoginResponseDto(
10 val token: String?
11)5.2. El modelo de sesión#
El DTO pertenece a la capa de red. Lo que el resto de la aplicación maneja es un modelo propio:
1package com.ejemplo.examplelogin.data.model
2
3/**
4 * Representa una sesión activa.
5 *
6 * @param token cadena JWT completa devuelta por la API.
7 * @param caducaEnMs instante de caducidad en milisegundos (época Unix),
8 * o null si el token no contiene un claim `exp` legible.
9 */
10// ─── data/model/Sesion.kt ────────────────────────────────────────────────────────────────────────
11data class Sesion(
12 val token: String,
13 val caducaEnMs: Long?
14)5.3. La interfaz de servicio de Retrofit#
1package com.ejemplo.examplelogin.data.datasource.remote
2
3import com.ejemplo.examplelogin.data.datasource.remote.dto.LoginRequestDto
4import com.ejemplo.examplelogin.data.datasource.remote.dto.LoginResponseDto
5import retrofit2.Response
6import retrofit2.http.Body
7import retrofit2.http.POST
8
9// ─── data/datasource/remote/LoginApiService.kt ───────────────────────────────────────────────────
10interface LoginApiService {
11
12 /**
13 * POST https://api.javiercarrasco.es/coffee/login
14 *
15 * Se devuelve `Response<T>` en lugar de `T` directamente para poder
16 * inspeccionar el código HTTP (400, 401...) sin capturar excepciones.
17 */
18 @POST("login")
19 suspend fun login(@Body credenciales: LoginRequestDto): Response<LoginResponseDto>
20}
Response<T>frente aT. Si la función declara el tipo del cuerpo directamente (suspend fun login(...): LoginResponseDto), Retrofit lanza unaHttpExceptionante cualquier código distinto de 2xx, y el control de errores se convierte en untry/catchcone.code(). DeclarandoResponse<T>el resultado siempre llega como valor y podemos usarisSuccessfulycode(), que es más legible y más fácil de probar.
6. Paso 3: el cliente Retrofit#
1package com.ejemplo.examplelogin.data.datasource.remote
2
3import com.ejemplo.examplelogin.BuildConfig // para acceder a BuildConfig.DEBUG se debe constructir el proyecto al menos una vez
4import okhttp3.OkHttpClient
5import okhttp3.logging.HttpLoggingInterceptor
6import retrofit2.Retrofit
7import retrofit2.converter.gson.GsonConverterFactory
8import java.util.concurrent.TimeUnit
9
10// ─── data/datasource/remote/RetrofitClient.kt ────────────────────────────────────────────────────
11object RetrofitClient {
12
13 private const val URL_BASE = "https://api.javiercarrasco.es/coffee/"
14
15 /**
16 * Interceptor que vuelca en el Logcat la petición y la respuesta completas.
17 * En una compilación de release se desactiva: el cuerpo de /login contiene
18 * la contraseña en claro y el token.
19 */
20 private val loggingInterceptor = HttpLoggingInterceptor().apply {
21 level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
22 else HttpLoggingInterceptor.Level.NONE
23 }
24
25 private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
26 .addInterceptor(loggingInterceptor)
27 .connectTimeout(15, TimeUnit.SECONDS)
28 .readTimeout(15, TimeUnit.SECONDS)
29 .build()
30
31 val retrofit: LoginApiService by lazy {
32 Retrofit.Builder()
33 .baseUrl(URL_BASE) // debe terminar en "/"
34 .client(okHttpClient)
35 .addConverterFactory(GsonConverterFactory.create())
36 .build()
37 .create(LoginApiService::class.java)
38 }
39}La URL base debe terminar en
/. Retrofit combinabaseUrlcon la ruta de la anotación ("login"). Si la base no acaba en barra, se sustituye el último segmento y la petición acabaría dirigiéndose ahttps://api.javiercarrasco.es/login.
Cuidado con el nivel
BODYen producción. Es la herramienta de depuración más útil que tenemos, pero escribe en el Logcat las credenciales y el token. La condiciónBuildConfig.DEBUGgarantiza que solo esté activo en compilaciones de depuración.
7. Paso 4: RemoteDataSource y modelado de errores#
Un error de red no es una excepción que deba propagarse hasta la UI. La capa de datos traduce todos los resultados posibles a un tipo cerrado que el ViewModel podrás recorrer con un when exhaustivo.
1package com.ejemplo.examplelogin.data.model
2
3/**
4 * Resultado de un intento de autenticación.
5 * Al ser una interfaz sellada, el compilador obliga a tratar todos los casos
6 * en los `when` que la consumen: si mañana añadimos un caso nuevo, el
7 * compilador señalará todos los puntos que hay que revisar.
8 */
9// ─── data/model/ResultadoLogin.kt ────────────────────────────────────────────────────────────────
10sealed interface ResultadoLogin {
11 data class Exito(val token: String) : ResultadoLogin
12 data object CredencialesIncorrectas : ResultadoLogin
13 data object DatosIncompletos : ResultadoLogin
14 data object SinConexion : ResultadoLogin
15 data class ErrorServidor(val codigo: Int) : ResultadoLogin
16 data object RespuestaInesperada : ResultadoLogin
17} 1package com.ejemplo.examplelogin.data.datasource.remote
2
3import com.ejemplo.examplelogin.data.datasource.remote.dto.LoginRequestDto
4import com.ejemplo.examplelogin.data.model.ResultadoLogin
5import java.io.IOException
6
7// ─── data/datasource/remote/RemoteDataSource.kt ──────────────────────────────────────────────────
8class RemoteDataSource(private val apiService: LoginApiService = RetrofitClient.retrofit) {
9
10 suspend fun login(usuario: String, password: String): ResultadoLogin {
11 return try {
12 val respuesta = apiService.login(LoginRequestDto(usuario, password))
13
14 if (respuesta.isSuccessful) {
15 val token = respuesta.body()?.token
16 if (token.isNullOrBlank()) ResultadoLogin.RespuestaInesperada
17 else ResultadoLogin.Exito(token)
18 } else {
19 when (respuesta.code()) {
20 400 -> ResultadoLogin.DatosIncompletos
21 401, 403 -> ResultadoLogin.CredencialesIncorrectas
22 else -> ResultadoLogin.ErrorServidor(respuesta.code())
23 }
24 }
25 } catch (e: IOException) {
26 // Sin red, DNS que no resuelve, timeout de conexión o de lectura.
27 ResultadoLogin.SinConexion
28 } catch (e: Exception) {
29 // JSON malformado, error de conversión de Gson, etc.
30 ResultadoLogin.RespuestaInesperada
31 }
32 }
33}No hace falta
withContext(Dispatchers.IO). Las funcionessuspendgeneradas por Retrofit son main-safe: internamente encolan la llamada en el dispatcher de OkHttp y suspenden la corrutina, sin bloquear el hilo principal. Añadir unwithContextsería redundante. Sí sería necesario si dentro de la función se hiciese un trabajo bloqueante propio.
8. Paso 5: persistir el token con DataStore Preferences#
Es necesario que el token sobreviva al cierre de la aplicación. La opción histórica en Android era SharedPreferences, pero la documentación oficial recomienda DataStore para todo código nuevo: es la biblioteca de Jetpack que sustituye a SharedPreferences para el almacenamiento de pares clave-valor.
8.1. Por qué DataStore#
SharedPreferences |
DataStore Preferences | |
|---|---|---|
| API | Síncrona (getString) |
Asíncrona (Flow + funciones suspend) |
| Hilo principal | Puede bloquearlo (commit(), primera carga del fichero) |
Nunca lo bloquea |
| Errores de E/S | Silenciosos o excepciones no declaradas | Se propagan por el Flow y se pueden tratar |
| Escrituras | apply() no informa del resultado |
Transaccionales; edit { } suspende hasta confirmar |
| Observación de cambios | OnSharedPreferenceChangeListener (manual, hay que desregistrar) |
El Flow emite automáticamente en cada cambio |
| Consistencia | Sin garantías atómicas entre claves | Actualizaciones atómicas |
Existen dos variantes de DataStore:
- Preferences DataStore: guarda pares clave-valor, sin esquema. Es el sustituto directo de
SharedPreferencesy el que usaremos. - Proto DataStore: guarda objetos tipados definidos con Protocol Buffers. Ofrece seguridad de tipos en tiempo de compilación, pero exige definir un esquema
.proto.
La consecuencia de diseño más importante. Al ser asíncrono, DataStore no permite preguntar «¿hay sesión?» de forma inmediata en
onCreate(). La aplicación arranca sin saberlo todavía y debe mostrar una pantalla de carga durante el primer valor delFlow. Esto no es un inconveniente, sino la forma correcta de modelarlo: se verá en el apartado 13 que convierte la sesión en una única fuente de verdad reactiva de la que toda la interfaz depende automáticamente.
8.2. La fuente de datos local#
1package com.ejemplo.examplelogin.data.datasource.local
2
3import android.content.Context
4import androidx.datastore.core.DataStore
5import androidx.datastore.preferences.core.Preferences
6import androidx.datastore.preferences.core.edit
7import androidx.datastore.preferences.core.emptyPreferences
8import androidx.datastore.preferences.core.stringPreferencesKey
9import androidx.datastore.preferences.preferencesDataStore
10import kotlinx.coroutines.flow.Flow
11import kotlinx.coroutines.flow.catch
12import kotlinx.coroutines.flow.map
13import java.io.IOException
14
15// ─── data/datasource/local/SesionLocalDataSource.kt ──────────────────────────────────────────────
16
17/**
18 * Delegado que crea el DataStore. DEBE declararse a nivel de fichero (top level):
19 * garantiza que exista una única instancia por nombre de fichero en todo el proceso.
20 * Crear dos DataStore sobre el mismo fichero provoca una IllegalStateException.
21 *
22 * El fichero resultante es: /data/data/<paquete>/files/datastore/sesion_login.preferences_pb
23 */
24private val Context.dataStoreSesion: DataStore<Preferences> by preferencesDataStore(
25 name = "sesion_login"
26)
27
28/**
29 * Fuente de datos local. Encapsula por completo el acceso a DataStore:
30 * ninguna otra clase conoce el nombre del fichero ni el de las claves.
31 */
32class SesionLocalDataSource(private val context: Context) {
33
34 /**
35 * Flujo con el token almacenado (null si no hay sesión).
36 * Emite un valor nuevo cada vez que el contenido del DataStore cambia.
37 */
38 val token: Flow<String?> = context.dataStoreSesion.data
39 .catch { excepcion ->
40 // Un fichero corrupto o ilegible lanza IOException. En ese caso
41 // emitimos preferencias vacías (equivale a "no hay sesión") en
42 // lugar de dejar que la excepción rompa la recolección.
43 if (excepcion is IOException) emit(emptyPreferences()) else throw excepcion
44 }
45 .map { preferencias -> preferencias[Claves.TOKEN] }
46
47 /** Guarda el token. La función suspende hasta que la escritura se confirma en disco. */
48 suspend fun guardarToken(token: String) {
49 context.dataStoreSesion.edit { preferencias ->
50 preferencias[Claves.TOKEN] = token
51 }
52 }
53
54 /** Elimina la sesión almacenada. */
55 suspend fun borrarSesion() {
56 context.dataStoreSesion.edit { preferencias ->
57 preferencias.remove(Claves.TOKEN)
58 }
59 }
60
61 private object Claves {
62 /**
63 * Las claves de DataStore están tipadas: stringPreferencesKey solo
64 * admite valores String. Existen equivalentes para Int, Boolean, etc.
65 */
66 val TOKEN = stringPreferencesKey("token")
67 }
68}El delegado
preferencesDataStorea nivel de fichero. Es un requisito de la biblioteca, no una preferencia de estilo. Si se declara dentro de la clase, cada instancia crearía su propio DataStore sobre el mismo fichero y la biblioteca lanzaráIllegalStateException: There are multiple DataStores active for the same file.
Usa siempre el contexto de aplicación. El
AppContainer(apartado 11) construye esta clase conapplicationContext. Pasarle el contexto de unaActivityretendría esaActivitymientras viva el DataStore y provocaría una fuga de memoria.
8.3. Limitaciones de seguridad#
DataStore tampoco cifra el contenido. El fichero
.preferences_pbes privado de la aplicación —inaccesible desde otras apps en un dispositivo sin root—, pero su contenido no está cifrado. Para un token de práctica es suficiente; en producción habría que cifrar el valor con una clave del Android Keystore antes de guardarlo. Ten en cuenta queandroidx.security:security-crypto(EncryptedSharedPreferences) está descontinuada y no debe usarse en proyectos nuevos.
9. Paso 6: comprobar la caducidad del token#
El payload del JWT contiene el claim exp: el instante de caducidad en segundos desde la época Unix. Es posible leerlo sin ninguna dependencia externa, usando android.util.Base64 y org.json.JSONObject, ambos incluidos en el SDK de Android.
1package com.ejemplo.examplelogin.utils
2
3import android.util.Base64
4import org.json.JSONObject
5
6/**
7 * Utilidades para leer (que NO validar) un token JWT en el cliente.
8 */
9// ─── utils/Jwt.kt ────────────────────────────────────────────────────────────────────────────────
10object Jwt {
11
12 /**
13 * Extrae el claim `exp` del payload y lo devuelve en milisegundos.
14 *
15 * @return instante de caducidad en ms (época Unix), o null si el token
16 * no tiene el formato esperado o carece del claim `exp`.
17 */
18 fun caducidadMs(token: String): Long? {
19 return try {
20 val partes = token.split(".")
21 if (partes.size < 2) return null
22
23 // El payload viaja en Base64 URL-safe y normalmente sin relleno '='.
24 val bytes = Base64.decode(
25 partes[1],
26 Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
27 )
28 val payload = JSONObject(String(bytes, Charsets.UTF_8))
29
30 // optLong devuelve 0 si el claim no existe, en lugar de lanzar excepción.
31 val expSegundos = payload.optLong("exp", 0L)
32 if (expSegundos <= 0L) null else expSegundos * 1_000L
33 } catch (e: Exception) {
34 null
35 }
36 }
37
38 /**
39 * Indica si el token ya ha caducado.
40 *
41 * @param margenMs margen de seguridad: se considera caducado un poco antes
42 * de tiempo para evitar que expire justo durante una petición.
43 * @return false si el token no declara caducidad (no podemos afirmar que haya caducado).
44 */
45 fun haCaducado(token: String, margenMs: Long = 30_000L): Boolean {
46 val caducidad = caducidadMs(token) ?: return false
47 return System.currentTimeMillis() >= caducidad - margenMs
48 }
49}Leer no es validar. Decodificar el payload solo permite anticiparse: evitar una llamada que se sabe que va a fallar y avisar al usuario. La única comprobación con valor de seguridad es la que hace el servidor al verificar la firma. Por eso la aplicación debe seguir tratando cualquier
401como sesión expirada, aunque su reloj local diga que el token todavía es válido.
El reloj del dispositivo puede estar desajustado.
System.currentTimeMillis()devuelve la hora del dispositivo, que el usuario puede cambiar. Es otra razón para no confiar únicamente en la comprobación local.
10. Paso 7: el repositorio#
El repositorio es el único punto de la aplicación que conoce ambas fuentes de datos y decide la política: qué se guarda, cuándo se borra y qué se considera una sesión válida.
Con DataStore, el repositorio ya no expone una función que consulta la sesión, sino un flujo que la publica. Cualquier cambio en el almacenamiento —un login, un logout, una caducidad— se propaga solo hasta la interfaz.
1package com.ejemplo.examplelogin.data.repository
2
3import com.ejemplo.examplelogin.data.datasource.local.SesionLocalDataSource
4import com.ejemplo.examplelogin.data.datasource.remote.RemoteDataSource
5import com.ejemplo.examplelogin.data.model.ResultadoLogin
6import com.ejemplo.examplelogin.data.model.Sesion
7import com.ejemplo.examplelogin.utils.Jwt
8import kotlinx.coroutines.flow.Flow
9import kotlinx.coroutines.flow.map
10
11// ─── data/repository/AutenticacionRepository.kt ──────────────────────────────────────────────────
12class AutenticacionRepository(
13 private val remoto: RemoteDataSource,
14 private val local: SesionLocalDataSource
15) {
16
17 /**
18 * Sesión actual de la aplicación: única fuente de verdad.
19 *
20 * Emite null cuando no hay token guardado o cuando el que hay ya ha
21 * caducado; en caso contrario, emite la sesión con su fecha de caducidad.
22 */
23 val sesion: Flow<Sesion?> = local.token.map { token ->
24 when {
25 token == null -> null
26 Jwt.haCaducado(token) -> null
27 else -> Sesion(token = token, caducaEnMs = Jwt.caducidadMs(token))
28 }
29 }
30
31 /**
32 * Intenta autenticar al usuario. Si tiene éxito, persiste el token:
33 * la escritura hace que `sesion` emita automáticamente la nueva sesión.
34 */
35 suspend fun login(usuario: String, password: String): ResultadoLogin {
36 val resultado = remoto.login(usuario.trim(), password)
37 if (resultado is ResultadoLogin.Exito) {
38 local.guardarToken(resultado.token)
39 }
40 return resultado
41 }
42
43 /**
44 * Cierra la sesión. Se usa tanto para el logout explícito del usuario
45 * como para descartar un token caducado.
46 */
47 suspend fun cerrarSesion() {
48 local.borrarSesion()
49 }
50}Por qué el
mapno borra el token caducado. Sería tentador llamar alocal.borrarSesion()dentro delmapal detectar la caducidad, pero un operador de transformación no debe provocar efectos secundarios: la escritura haría emitir de nuevo al propio flujo que se está transformando, y el flujo de datos se vuelve difícil de razonar. La limpieza se hace de forma explícita desde elTokenViewModel(apartado 16), que es quien detecta el vencimiento.
El repositorio no expone
Flow<String>con el token, sinoFlow<Sesion?>. La regla es que cada capa entrega a la siguiente el concepto que necesita, no el dato en bruto. El ViewModel no debe saber que la sesión se materializa en un JWT.
11. Paso 8: contenedor de dependencias y clase Application#
1package com.ejemplo.examplelogin.data.di
2
3import android.content.Context
4import com.ejemplo.examplelogin.data.datasource.local.SesionLocalDataSource
5import com.ejemplo.examplelogin.data.datasource.remote.RemoteDataSource
6import com.ejemplo.examplelogin.data.repository.AutenticacionRepository
7import com.ejemplo.examplelogin.utils.ObservadorConectividad
8
9/**
10 * Contenedor manual de dependencias: crea una única instancia de cada
11 * colaborador y la comparte con toda la aplicación. Sustituye a Hilt/Koin
12 * en proyectos de este tamaño.
13 */
14// ─── data/di/AppContainer.kt ─────────────────────────────────────────────────────────────────────
15class AppContainer(context: Context) {
16
17 private val remoteDataSource = RemoteDataSource()
18 private val sesionLocalDataSource = SesionLocalDataSource(context)
19
20 val autenticacionRepository: AutenticacionRepository =
21 AutenticacionRepository(remoteDataSource, sesionLocalDataSource)
22
23 val observadorConectividad: ObservadorConectividad =
24 ObservadorConectividad(context)
25} 1package com.ejemplo.examplelogin
2
3import android.app.Application
4import com.ejemplo.examplelogin.data.di.AppContainer
5
6// ─── ExampleLoginApplication.kt ──────────────────────────────────────────────────────────────────
7class ExampleLoginApplication : Application() {
8
9 lateinit var contenedor: AppContainer
10 private set
11
12 override fun onCreate() {
13 super.onCreate()
14 // applicationContext evita retener el contexto de una Activity concreta.
15 contenedor = AppContainer(applicationContext)
16 }
17}Y hay que declararla en el manifiesto:
1<application
2 android:name=".ExampleLoginApplication"
3 ... >Error frecuente. Si se olvida
android:nameen el manifiesto, Android instancia la claseApplicationpor defecto y el castapplication as ExampleLoginApplicationlanza unaClassCastExceptional arrancar.
12. Paso 9: observar el estado de la conexión#
Reutilizamos el observador presentado en T6. Si ya has visto ese tema, puedes copiar la clase tal cual.
1package com.ejemplo.examplelogin.utils
2
3import android.content.Context
4import android.net.ConnectivityManager
5import android.net.Network
6import android.net.NetworkCapabilities
7import android.net.NetworkRequest
8import kotlinx.coroutines.channels.awaitClose
9import kotlinx.coroutines.flow.Flow
10import kotlinx.coroutines.flow.callbackFlow
11import kotlinx.coroutines.flow.conflate
12import kotlinx.coroutines.flow.distinctUntilChanged
13
14// ─── utils/ObservadorConectividad.kt ─────────────────────────────────────────────────────────────
15class ObservadorConectividad(context: Context) {
16
17 private val gestor = context.getSystemService(ConnectivityManager::class.java)
18
19 /**
20 * Flujo que emite true cuando hay una red conectada y VALIDADA
21 * (con salida real a internet), y false en caso contrario.
22 *
23 * callbackFlow adapta una API basada en callbacks a un Flow de corrutinas:
24 * registra el callback al empezar a recolectar y lo libera en awaitClose.
25 */
26 val estado: Flow<Boolean> = callbackFlow {
27 val callback = object : ConnectivityManager.NetworkCallback() {
28
29 override fun onCapabilitiesChanged(red: Network, caps: NetworkCapabilities) {
30 trySend(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
31 }
32
33 override fun onLost(red: Network) { trySend(false) }
34 override fun onUnavailable() { trySend(false) }
35 }
36
37 val peticion = NetworkRequest.Builder()
38 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
39 .build()
40
41 gestor.registerNetworkCallback(peticion, callback)
42 trySend(hayConexion()) // valor inicial
43
44 awaitClose { gestor.unregisterNetworkCallback(callback) }
45 }.distinctUntilChanged().conflate()
46
47 /** Comprobación puntual y síncrona del estado de la red. */
48 fun hayConexion(): Boolean {
49 val red = gestor.activeNetwork ?: return false
50 val caps = gestor.getNetworkCapabilities(red) ?: return false
51 return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
52 }
53}
NET_CAPABILITY_INTERNETfrente aNET_CAPABILITY_VALIDATED. La primera indica que la red dice dar acceso a internet; la segunda, que Android lo ha comprobado realmente. Es la diferencia entre estar conectado a un wifi de hotel con portal cautivo y tener internet de verdad.
13. Paso 10: el estado global de sesión#
En este apartado se introduce DataStore. Como la lectura es asíncrona, la aplicación pasa por tres estados posibles:
1package com.ejemplo.examplelogin.data.model
2
3/**
4 * Estado de la sesión desde el punto de vista de la interfaz.
5 *
6 * `Comprobando` es el estado inicial: DataStore todavía no ha entregado
7 * su primer valor y la aplicación aún no sabe si hay sesión guardada.
8 */
9// ─── data/model/EstadoSesion.kt ──────────────────────────────────────────────────────────────────
10sealed interface EstadoSesion {
11 data object Comprobando : EstadoSesion
12 data object NoAutenticada : EstadoSesion
13 data class Activa(val sesion: Sesion) : EstadoSesion
14} 1package com.ejemplo.examplelogin.screens.sesion
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModelProvider
5import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
6import androidx.lifecycle.viewModelScope
7import androidx.lifecycle.viewmodel.initializer
8import androidx.lifecycle.viewmodel.viewModelFactory
9import com.ejemplo.examplelogin.ExampleLoginApplication
10import com.ejemplo.examplelogin.data.model.EstadoSesion
11import com.ejemplo.examplelogin.data.repository.AutenticacionRepository
12import kotlinx.coroutines.flow.SharingStarted
13import kotlinx.coroutines.flow.StateFlow
14import kotlinx.coroutines.flow.map
15import kotlinx.coroutines.flow.stateIn
16
17/**
18 * ViewModel de ámbito de aplicación: traduce el flujo del repositorio al
19 * estado que consume el grafo de navegación.
20 */
21// ─── screens/sesion/SesionViewModel.kt ───────────────────────────────────────────────────────────
22class SesionViewModel(repositorio: AutenticacionRepository) : ViewModel() {
23
24 val estado: StateFlow<EstadoSesion> = repositorio.sesion
25 .map { sesion ->
26 if (sesion == null) EstadoSesion.NoAutenticada
27 else EstadoSesion.Activa(sesion)
28 }
29 .stateIn(
30 scope = viewModelScope,
31 // Mantiene la suscripción 5 s tras perder el último recolector,
32 // para no reiniciar la lectura ante un cambio de configuración.
33 started = SharingStarted.WhileSubscribed(5_000),
34 // Valor inicial: aún no sabemos nada.
35 initialValue = EstadoSesion.Comprobando
36 )
37
38 companion object {
39 val Factory: ViewModelProvider.Factory = viewModelFactory {
40 initializer {
41 val aplicacion = this[APPLICATION_KEY] as ExampleLoginApplication
42 SesionViewModel(aplicacion.contenedor.autenticacionRepository)
43 }
44 }
45 }
46}
stateInconvierte unFlowfrío en unStateFlowcaliente. ElFlowde DataStore es frío: cada recolector abriría su propia lectura del fichero.stateInlo comparte entre todos los recolectores, guarda el último valor emitido y proporciona elinitialValueque la interfaz necesita para pintar algo antes de la primera lectura.
Una única fuente de verdad. Ninguna pantalla decide ya “ahora estoy autenticado”. Login y logout se limitan a escribir en DataStore; el flujo emite,
SesionViewModeltraduce, y la navegación reacciona. Este es el patrón que la guía de arquitectura de Android llama unidirectional data flow.
14. Paso 11: LoginViewModel y estado de la interfaz#
1package com.ejemplo.examplelogin.screens.login
2
3// ─── screens/login/LoginUiState.kt ───────────────────────────────────────────────────────────────
4data class LoginUiState(
5 val usuario: String = "",
6 val password: String = "",
7 val cargando: Boolean = false,
8 val mensajeError: String? = null,
9 val hayConexion: Boolean = true
10) {
11 /** El botón solo se habilita si ambos campos tienen contenido. */
12 val formularioValido: Boolean
13 get() = usuario.isNotBlank() && password.isNotBlank()
14}No hace falta un campo
loginCorrecto. La pantalla de login no navega: cuando el repositorio guarda el token, elFlowde sesión emite y el grafo de navegación redirige por su cuenta (apartado 17). La pantalla solo tiene que ocuparse de su formulario.
1package com.ejemplo.examplelogin.screens.login
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModelProvider
5import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
6import androidx.lifecycle.viewModelScope
7import androidx.lifecycle.viewmodel.initializer
8import androidx.lifecycle.viewmodel.viewModelFactory
9import com.ejemplo.examplelogin.ExampleLoginApplication
10import com.ejemplo.examplelogin.data.model.ResultadoLogin
11import com.ejemplo.examplelogin.data.repository.AutenticacionRepository
12import com.ejemplo.examplelogin.utils.ObservadorConectividad
13import kotlinx.coroutines.flow.MutableStateFlow
14import kotlinx.coroutines.flow.StateFlow
15import kotlinx.coroutines.flow.asStateFlow
16import kotlinx.coroutines.flow.update
17import kotlinx.coroutines.launch
18
19// ─── screens/login/LoginViewModel.kt ─────────────────────────────────────────────────────────────
20class LoginViewModel(
21 private val repositorio: AutenticacionRepository,
22 observadorConectividad: ObservadorConectividad
23) : ViewModel() {
24
25 private val _uiState = MutableStateFlow(LoginUiState())
26 val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
27
28 init {
29 // Mantiene el estado de conexión sincronizado mientras vive el ViewModel.
30 viewModelScope.launch {
31 observadorConectividad.estado.collect { conectado ->
32 _uiState.update { it.copy(hayConexion = conectado) }
33 }
34 }
35 }
36
37 fun alCambiarUsuario(valor: String) {
38 _uiState.update { it.copy(usuario = valor, mensajeError = null) }
39 }
40
41 fun alCambiarPassword(valor: String) {
42 _uiState.update { it.copy(password = valor, mensajeError = null) }
43 }
44
45 fun iniciarSesion() {
46 val estado = _uiState.value
47 if (!estado.formularioValido || estado.cargando) return
48
49 if (!estado.hayConexion) {
50 _uiState.update { it.copy(mensajeError = "Sin conexión a internet.") }
51 return
52 }
53
54 viewModelScope.launch {
55 _uiState.update { it.copy(cargando = true, mensajeError = null) }
56
57 when (val resultado = repositorio.login(estado.usuario, estado.password)) {
58 // No se navega desde aquí: al guardarse el token, el flujo de
59 // sesión emite y el grafo de navegación redirige solo.
60 is ResultadoLogin.Exito ->
61 _uiState.update { it.copy(cargando = false) }
62
63 ResultadoLogin.CredencialesIncorrectas ->
64 mostrarError("Usuario o contraseña incorrectos.")
65
66 ResultadoLogin.DatosIncompletos ->
67 mostrarError("Debes rellenar usuario y contraseña.")
68
69 ResultadoLogin.SinConexion ->
70 mostrarError("No se ha podido contactar con el servidor. Revisa tu conexión.")
71
72 is ResultadoLogin.ErrorServidor ->
73 mostrarError("Error del servidor (${resultado.codigo}). Inténtalo más tarde.")
74
75 ResultadoLogin.RespuestaInesperada ->
76 mostrarError("Respuesta inesperada del servidor.")
77 }
78 }
79 }
80
81 private fun mostrarError(mensaje: String) {
82 _uiState.update { it.copy(cargando = false, mensajeError = mensaje) }
83 }
84
85 companion object {
86 val Factory: ViewModelProvider.Factory = viewModelFactory {
87 initializer {
88 val aplicacion = this[APPLICATION_KEY] as ExampleLoginApplication
89 LoginViewModel(
90 repositorio = aplicacion.contenedor.autenticacionRepository,
91 observadorConectividad = aplicacion.contenedor.observadorConectividad
92 )
93 }
94 }
95 }
96}Los mensajes de error, ¿en el ViewModel o en la UI? Aquí se construyen en el ViewModel por sencillez. En una aplicación multiidioma lo correcto sería que el ViewModel expusiera el identificador del recurso (
R.string.error_credenciales) o el propio tipo de error, y que la UI hiciera la traducción constringResource(). Se plantea como ampliación en el apartado 20.
15. Paso 12: la pantalla de login#
1package com.ejemplo.examplelogin.screens.login
2
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.text.KeyboardOptions
5import androidx.compose.material3.*
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.getValue
8import androidx.compose.ui.Alignment
9import androidx.compose.ui.Modifier
10import androidx.compose.ui.text.input.ImeAction
11import androidx.compose.ui.text.input.KeyboardType
12import androidx.compose.ui.text.input.PasswordVisualTransformation
13import androidx.compose.ui.unit.dp
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15import androidx.lifecycle.viewmodel.compose.viewModel
16
17// ─── screens/login/PantallaLogin.kt ──────────────────────────────────────────────────────────────
18@OptIn(ExperimentalMaterial3Api::class)
19@Composable
20fun PantallaLogin(
21 modifier: Modifier = Modifier,
22 viewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory)
23) {
24 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
25
26 Scaffold(
27 topBar = { TopAppBar(title = { Text("Acceso") }) },
28 modifier = modifier
29 ) { padding ->
30 Column(
31 modifier = Modifier
32 .fillMaxSize()
33 .padding(padding)
34 .padding(24.dp),
35 verticalArrangement = Arrangement.Center,
36 horizontalAlignment = Alignment.CenterHorizontally
37 ) {
38
39 // Aviso permanente mientras no haya red.
40 if (!uiState.hayConexion) {
41 Card(
42 colors = CardDefaults.cardColors(
43 containerColor = MaterialTheme.colorScheme.errorContainer
44 ),
45 modifier = Modifier.fillMaxWidth()
46 ) {
47 Text(
48 text = "Sin conexión a internet",
49 color = MaterialTheme.colorScheme.onErrorContainer,
50 modifier = Modifier.padding(12.dp)
51 )
52 }
53 Spacer(Modifier.height(16.dp))
54 }
55
56 OutlinedTextField(
57 value = uiState.usuario,
58 onValueChange = viewModel::alCambiarUsuario,
59 label = { Text("Usuario") },
60 singleLine = true,
61 enabled = !uiState.cargando,
62 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
63 modifier = Modifier.fillMaxWidth()
64 )
65
66 Spacer(Modifier.height(12.dp))
67
68 OutlinedTextField(
69 value = uiState.password,
70 onValueChange = viewModel::alCambiarPassword,
71 label = { Text("Contraseña") },
72 singleLine = true,
73 enabled = !uiState.cargando,
74 visualTransformation = PasswordVisualTransformation(),
75 keyboardOptions = KeyboardOptions(
76 keyboardType = KeyboardType.Password,
77 imeAction = ImeAction.Done
78 ),
79 modifier = Modifier.fillMaxWidth()
80 )
81
82 Spacer(Modifier.height(8.dp))
83
84 // Mensaje de error, si lo hay.
85 uiState.mensajeError?.let { mensaje ->
86 Text(
87 text = mensaje,
88 color = MaterialTheme.colorScheme.error,
89 style = MaterialTheme.typography.bodyMedium,
90 modifier = Modifier.fillMaxWidth()
91 )
92 Spacer(Modifier.height(8.dp))
93 }
94
95 Button(
96 onClick = viewModel::iniciarSesion,
97 enabled = uiState.formularioValido && !uiState.cargando,
98 modifier = Modifier.fillMaxWidth()
99 ) {
100 if (uiState.cargando) {
101 CircularProgressIndicator(
102 modifier = Modifier.size(20.dp),
103 strokeWidth = 2.dp,
104 color = MaterialTheme.colorScheme.onPrimary
105 )
106 Spacer(Modifier.width(8.dp))
107 Text("Accediendo…")
108 } else {
109 Text("Iniciar sesión")
110 }
111 }
112 }
113 }
114}Se añadirá también una pantalla de carga mínima para el estado Comprobando, el usuario tiene que saber que la aplicación está trabajando mientras DataStore entrega su primer valor:
1package com.ejemplo.examplelogin.screens.sesion
2
3import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.material3.CircularProgressIndicator
6import androidx.compose.runtime.Composable
7import androidx.compose.ui.Alignment
8import androidx.compose.ui.Modifier
9
10// ─── screens/sesion/PantallaCargando.kt ──────────────────────────────────────────────────────────
11/** Se muestra mientras DataStore entrega su primer valor. */
12@Composable
13fun PantallaCargando(modifier: Modifier = Modifier) {
14 Box(
15 modifier = modifier.fillMaxSize(),
16 contentAlignment = Alignment.Center
17 ) {
18 CircularProgressIndicator()
19 }
20}Por qué
collectAsStateWithLifecycle()y nocollectAsState(). La primera detiene la recolección cuando la pantalla pasa a segundo plano (STOPPED) y la reanuda al volver. ConcollectAsState()el flujo se seguiría recolectando con la aplicación en segundo plano, consumiendo recursos innecesariamente. Requierelifecycle-runtime-compose.
16. Paso 13: TokenViewModel y la pantalla del token#
1package com.ejemplo.examplelogin.screens.token
2
3// ─── screens/token/TokenUiState.kt ───────────────────────────────────────────────────────────────
4data class TokenUiState(
5 val token: String = "",
6 val caducaEnMs: Long? = null,
7 val segundosRestantes: Long? = null
8) 1package com.ejemplo.examplelogin.screens.token
2
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.ViewModelProvider
5import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
6import androidx.lifecycle.viewModelScope
7import androidx.lifecycle.viewmodel.initializer
8import androidx.lifecycle.viewmodel.viewModelFactory
9import com.ejemplo.examplelogin.ExampleLoginApplication
10import com.ejemplo.examplelogin.data.model.Sesion
11import com.ejemplo.examplelogin.data.repository.AutenticacionRepository
12import kotlinx.coroutines.ExperimentalCoroutinesApi
13import kotlinx.coroutines.delay
14import kotlinx.coroutines.flow.Flow
15import kotlinx.coroutines.flow.SharingStarted
16import kotlinx.coroutines.flow.StateFlow
17import kotlinx.coroutines.flow.collectLatest
18import kotlinx.coroutines.flow.flatMapLatest
19import kotlinx.coroutines.flow.flow
20import kotlinx.coroutines.flow.stateIn
21import kotlinx.coroutines.launch
22import kotlin.time.Duration.Companion.milliseconds
23
24// ─── screens/token/TokenViewModel.kt ─────────────────────────────────────────────────────────────
25@OptIn(ExperimentalCoroutinesApi::class)
26class TokenViewModel(private val repositorio: AutenticacionRepository) : ViewModel() {
27
28 /**
29 * Estado de la pantalla: los datos de la sesión más una cuenta atrás
30 * que se recalcula cada segundo.
31 *
32 * flatMapLatest cancela la cuenta atrás anterior cada vez que cambia la
33 * sesión, evitando que queden corrutinas huérfanas actualizando el estado.
34 */
35 val uiState: StateFlow<TokenUiState> = repositorio.sesion
36 .flatMapLatest { sesion -> cuentaAtras(sesion) }
37 .stateIn(
38 scope = viewModelScope,
39 started = SharingStarted.WhileSubscribed(5_000),
40 initialValue = TokenUiState()
41 )
42
43 init {
44 // Vigilante de caducidad: espera exactamente hasta el instante `exp`
45 // y entonces borra la sesión. Al escribir en DataStore, el flujo de
46 // sesión emite null y el grafo de navegación devuelve al login.
47 viewModelScope.launch {
48 repositorio.sesion.collectLatest { sesion ->
49 val caducidad = sesion?.caducaEnMs ?: return@collectLatest
50 val esperaMs = caducidad - System.currentTimeMillis()
51 if (esperaMs > 0) delay(esperaMs)
52 repositorio.cerrarSesion()
53 }
54 }
55 }
56
57 /** Logout explícito del usuario. */
58 fun cerrarSesion() {
59 viewModelScope.launch { repositorio.cerrarSesion() }
60 }
61
62 /**
63 * Emite un TokenUiState por segundo con el tiempo restante actualizado.
64 * Si el token no declara caducidad, emite una sola vez y termina.
65 */
66 private fun cuentaAtras(sesion: Sesion?): Flow<TokenUiState> = flow {
67 if (sesion == null) {
68 emit(TokenUiState())
69 return@flow
70 }
71 val caducidad = sesion.caducaEnMs
72 if (caducidad == null) {
73 emit(TokenUiState(token = sesion.token))
74 return@flow
75 }
76 while (true) {
77 val restantes = ((caducidad - System.currentTimeMillis()) / 1_000).coerceAtLeast(0L)
78 emit(
79 TokenUiState(
80 token = sesion.token,
81 caducaEnMs = caducidad,
82 segundosRestantes = restantes
83 )
84 )
85 if (restantes <= 0L) break
86 delay(1_000.milliseconds)
87 }
88 }
89
90 companion object {
91 val Factory: ViewModelProvider.Factory = viewModelFactory {
92 initializer {
93 val aplicacion = this[APPLICATION_KEY] as ExampleLoginApplication
94 TokenViewModel(aplicacion.contenedor.autenticacionRepository)
95 }
96 }
97 }
98}
collectLatestfrente acollect.collectLatestcancela el bloque en curso cuando llega un valor nuevo. Es justo lo que necesita el vigilante: si el usuario cierra sesión a mano mientras esperamos eldelayhasta la caducidad, esa espera se cancela en lugar de quedarse pendiente y borrar una sesión posterior.
1package com.ejemplo.examplelogin.screens.token
2
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.rememberScrollState
5import androidx.compose.foundation.text.selection.SelectionContainer
6import androidx.compose.foundation.verticalScroll
7import androidx.compose.material3.*
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.getValue
10import androidx.compose.ui.Modifier
11import androidx.compose.ui.platform.LocalLocale
12import androidx.compose.ui.text.font.FontFamily
13import androidx.compose.ui.unit.dp
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15import androidx.lifecycle.viewmodel.compose.viewModel
16import java.text.SimpleDateFormat
17import java.util.Date
18
19// ─── screens/token/PantallaToken.kt ──────────────────────────────────────────────────────────────
20@OptIn(ExperimentalMaterial3Api::class)
21@Composable
22fun PantallaToken(
23 modifier: Modifier = Modifier,
24 viewModel: TokenViewModel = viewModel(factory = TokenViewModel.Factory)
25) {
26 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
27
28 Scaffold(
29 topBar = { TopAppBar(title = { Text("Sesión iniciada") }) },
30 modifier = modifier
31 ) { padding ->
32 Column(
33 modifier = Modifier
34 .fillMaxSize()
35 .padding(padding)
36 .padding(16.dp)
37 .verticalScroll(rememberScrollState())
38 ) {
39 Text("Token recibido", style = MaterialTheme.typography.titleMedium)
40 Spacer(Modifier.height(8.dp))
41
42 // SelectionContainer permite copiar el token con una pulsación larga.
43 SelectionContainer {
44 Card(modifier = Modifier.fillMaxWidth()) {
45 Text(
46 text = uiState.token,
47 style = MaterialTheme.typography.bodySmall,
48 fontFamily = FontFamily.Monospace,
49 modifier = Modifier.padding(12.dp)
50 )
51 }
52 }
53
54 Spacer(Modifier.height(24.dp))
55
56 Text("Caducidad", style = MaterialTheme.typography.titleMedium)
57 Spacer(Modifier.height(8.dp))
58
59 val caducidad = uiState.caducaEnMs
60 if (caducidad == null) {
61 Text("El token no declara fecha de caducidad (claim `exp`).")
62 } else {
63 val formato =
64 SimpleDateFormat("dd/MM/yyyy HH:mm:ss", LocalLocale.current.platformLocale)
65 Text("Caduca el: ${formato.format(Date(caducidad))}")
66 uiState.segundosRestantes?.let { restantes ->
67 Text("Tiempo restante: ${formatearDuracion(restantes)}")
68 }
69 }
70
71 Spacer(Modifier.height(32.dp))
72
73 Button(
74 onClick = viewModel::cerrarSesion,
75 colors = ButtonDefaults.buttonColors(
76 containerColor = MaterialTheme.colorScheme.error
77 ),
78 modifier = Modifier.fillMaxWidth()
79 ) {
80 Text("Cerrar sesión")
81 }
82 }
83 }
84}
85
86/** Convierte segundos en un texto del tipo "1 h 03 min 20 s". */
87private fun formatearDuracion(segundos: Long): String {
88 val horas = segundos / 3600
89 val minutos = (segundos % 3600) / 60
90 val resto = segundos % 60
91 return buildString {
92 if (horas > 0) append("$horas h ")
93 if (horas > 0 || minutos > 0) append("%02d min ".format(minutos))
94 append("%02d s".format(resto))
95 }
96}La pantalla del token no navega. No recibe ningún callback: pulsar «Cerrar sesión» solo borra el token del DataStore. La redirección la hace el grafo de navegación al observar el cambio de estado.
17. Paso 14: navegación y arranque de la aplicación#
17.1. Rutas type-safe#
1package com.ejemplo.examplelogin.navegacion
2
3import kotlinx.serialization.Serializable
4
5/**
6 * Rutas type-safe de Navigation Compose (T3). Al ser objetos serializables,
7 * el compilador verifica los destinos: no hay cadenas mágicas que se puedan
8 * escribir mal.
9 */
10// ─── navegacion/Rutas.kt ─────────────────────────────────────────────────────────────────────────
11@Serializable
12object RutaLogin
13
14@Serializable
15object RutaToken17.2. El grafo de navegación#
1package com.ejemplo.examplelogin.navegacion
2
3import androidx.compose.runtime.Composable
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.runtime.getValue
6import androidx.compose.runtime.remember
7import androidx.lifecycle.compose.collectAsStateWithLifecycle
8import androidx.lifecycle.viewmodel.compose.viewModel
9import androidx.navigation.NavDestination.Companion.hasRoute
10import androidx.navigation.compose.NavHost
11import androidx.navigation.compose.composable
12import androidx.navigation.compose.rememberNavController
13import com.ejemplo.examplelogin.data.model.EstadoSesion
14import com.ejemplo.examplelogin.screens.login.PantallaLogin
15import com.ejemplo.examplelogin.screens.sesion.PantallaCargando
16import com.ejemplo.examplelogin.screens.sesion.SesionViewModel
17import com.ejemplo.examplelogin.screens.token.PantallaToken
18
19// ─── navegacion/NavegacionApp.kt ─────────────────────────────────────────────────────────────────
20@Composable
21fun NavegacionApp(
22 sesionViewModel: SesionViewModel = viewModel(factory = SesionViewModel.Factory)
23) {
24 val estado by sesionViewModel.estado.collectAsStateWithLifecycle()
25
26 // Mientras DataStore no entregue su primer valor, no sabemos qué mostrar.
27 when (val estadoActual = estado) {
28 EstadoSesion.Comprobando -> PantallaCargando()
29 else -> GrafoNavegacion(estadoSesion = estadoActual)
30 }
31}
32
33@Composable
34private fun GrafoNavegacion(estadoSesion: EstadoSesion) {
35 val navController = rememberNavController()
36
37 // El destino inicial se fija UNA sola vez: al entrar aquí el estado
38 // "Comprobando" ya está resuelto, así que sabemos si hay sesión.
39 val destinoInicial: Any = remember {
40 if (estadoSesion is EstadoSesion.Activa) RutaToken else RutaLogin
41 }
42
43 // A partir de ese momento, cualquier cambio de sesión —login, logout o
44 // caducidad— redirige automáticamente.
45 LaunchedEffect(estadoSesion) {
46 val destino: Any = if (estadoSesion is EstadoSesion.Activa) RutaToken else RutaLogin
47 val yaEstamosAhi = navController.currentDestination?.hasRoute(destino::class) == true
48 if (!yaEstamosAhi) {
49 navController.navigate(destino) {
50 // popUpTo(0) vacía la pila por completo: tras un cambio de
51 // sesión no debe quedar ninguna pantalla anterior accesible
52 // con el botón "atrás".
53 popUpTo(0) { inclusive = true }
54 }
55 }
56 }
57
58 NavHost(navController = navController, startDestination = destinoInicial) {
59 composable<RutaLogin> { PantallaLogin() }
60 composable<RutaToken> { PantallaToken() }
61 }
62}Por qué la comprobación
yaEstamosAhi.LaunchedEffectse ejecuta también en la primera composición, cuando el destino que toca ya es el destino inicial. Sin la comprobación se lanzaría una navegación redundante que recrearía la pantalla (y su ViewModel) nada más arrancar.
17.3. La MainActivity#
1package com.ejemplo.examplelogin
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import com.ejemplo.examplelogin.navegacion.NavegacionApp
8import com.ejemplo.examplelogin.ui.theme.ExampleLoginTheme
9
10// ─── MainActivity.kt ─────────────────────────────────────────────────────────────────────────────
11class MainActivity : ComponentActivity() {
12
13 override fun onCreate(savedInstanceState: Bundle?) {
14 super.onCreate(savedInstanceState)
15 enableEdgeToEdge()
16
17 // Ya no se comprueba la sesión aquí: DataStore es asíncrono y la
18 // comprobación la resuelve SesionViewModel dentro de NavegacionApp.
19 setContent {
20 ExampleLoginTheme {
21 NavegacionApp()
22 }
23 }
24 }
25}Evitar el parpadeo inicial. La pantalla de carga dura unos pocos milisegundos, pero se ve. Si quieres eliminarla, la solución oficial es la Splash Screen API (
androidx.core:core-splashscreen): se retiene la pantalla de arranque del sistema consplashScreen.setKeepOnScreenCondition { estado is Comprobando }hasta que la sesión esté resuelta. Se propone como ampliación en el apartado 20.
18. Estructura final del proyecto#
com.ejemplo.examplelogin/
├── ExampleLoginApplication.kt
├── MainActivity.kt
├── data/
│ ├── datasource/
│ │ ├── local/
│ │ │ └── SesionLocalDataSource.kt ← DataStore Preferences
│ │ └── remote/
│ │ ├── dto/
│ │ │ ├── LoginRequestDto.kt
│ │ │ └── LoginResponseDto.kt
│ │ ├── LoginApiService.kt
│ │ ├── RemoteDataSource.kt
│ │ └── RetrofitClient.kt
│ ├── di/
│ │ └── AppContainer.kt
│ ├── model/
│ │ ├── EstadoSesion.kt
│ │ ├── ResultadoLogin.kt
│ │ └── Sesion.kt
│ └── repository/
│ └── AutenticacionRepository.kt
├── navegacion/
│ ├── NavegacionApp.kt
│ └── Rutas.kt
├── screens/
│ ├── login/
│ │ ├── LoginUiState.kt
│ │ ├── LoginViewModel.kt
│ │ └── PantallaLogin.kt
│ ├── sesion/
│ │ ├── PantallaCargando.kt
│ │ └── SesionViewModel.kt
│ └── token/
│ ├── PantallaToken.kt
│ ├── TokenUiState.kt
│ └── TokenViewModel.kt
├── ui/theme/ ← generado por la plantilla
└── utils/
├── Jwt.kt
└── ObservadorConectividad.kt18.1. Recorrido completo de los datos#
Revisa el flujo de datos desde que el usuario pulsa “Iniciar sesión” hasta que la interfaz reacciona al token recibido:
Pulsar "Iniciar sesión"
└─> LoginViewModel.iniciarSesion()
└─> AutenticacionRepository.login()
├─> RemoteDataSource ──> Retrofit ──> POST /login ──> token
└─> SesionLocalDataSource.guardarToken() ──> DataStore (escritura)
│
DataStore emite un valor nuevo ◀───┘
│
AutenticacionRepository.sesion (Flow<Sesion?>)
│
SesionViewModel.estado (StateFlow<EstadoSesion>)
│
NavegacionApp ──> navega a RutaTokenY el logout es exactamente el mismo recorrido en sentido inverso: escribir en DataStore es lo único que hace falta para que toda la interfaz reaccione.
19. Pruebas de verificación#
Antes de dar la práctica por terminada, comprueba todos los puntos siguientes en un dispositivo o emulador:
| # | Prueba | Resultado esperado |
|---|---|---|
| 1 | Abrir la app por primera vez | Indicador de carga muy breve y, después, el formulario de login |
| 2 | Botón con algún campo vacío | El botón está deshabilitado |
| 3 | Login con alumno / alumno |
Se navega a la pantalla del token |
| 4 | Login con contraseña errónea | Mensaje «Usuario o contraseña incorrectos» y se permanece en el login |
| 5 | Login en modo avión | Aviso de «Sin conexión» y el botón queda inhabilitado |
| 6 | Activar el modo avión con la app abierta | El aviso aparece sin reiniciar la app |
| 7 | Pantalla del token | Se muestra el JWT completo, su fecha de caducidad y la cuenta atrás |
| 8 | Pulsar «atrás» en la pantalla del token | La app se cierra; no vuelve al login |
| 9 | Cerrar la app por completo y volver a abrirla | Entra directamente en la pantalla del token, sin pedir credenciales |
| 10 | Girar el dispositivo en la pantalla del token | El estado se conserva; la cuenta atrás no se reinicia |
| 11 | Esperar a que expire el token | La cuenta atrás llega a 0 y la app vuelve al login automáticamente |
| 12 | Pulsar «Cerrar sesión» | Se vuelve al login |
| 13 | Tras el logout, cerrar y reabrir la app | Aparece el login: el token se borró del almacenamiento |
Cómo inspeccionar el fichero de DataStore. En Android Studio,
View ▸ Tool Windows ▸ Device Explorer, y navega hasta/data/data/com.ejemplo.examplelogin/files/datastore/sesion_login.preferences_pb. Es un fichero binario en formato Protocol Buffers, así que no se lee como el XML deSharedPreferences, pero abriéndolo se distingue el token entre los bytes: buena ocasión para comentar en clase las limitaciones de seguridad del apartado 8.4.
Cómo forzar la caducidad sin esperar. Si el token dura demasiado para probarlo en clase, aumenta temporalmente el
margenMsdeJwt.haCaducado()(por ejemplo, a la duración total del token menos un minuto). Otra opción es añadir de forma provisional una función de depuración enSesionLocalDataSourceque guarde un JWT de prueba ya caducado.
20. Ampliaciones propuestas#
- Interceptor de autorización. Añade un
Interceptorde OkHttp que inserte la cabeceraAuthorization: Bearer <token>en todas las peticiones, y consumeGET /coffeepara listar los cafés en una tercera pantalla. Ojo: el interceptor es síncrono y el token vive en unFlow; habrá que usarrunBlocking { local.token.first() }dentro del interceptor (se ejecuta en el hilo de OkHttp, nunca en el principal) o mantener una copia en memoria del último token emitido. - Tratamiento global del 401. Añade un interceptor que, ante un
401, borre la sesión y provoque la vuelta al login, incluso si el reloj local decía que el token seguía siendo válido. - Splash Screen API. Sustituye
PantallaCargandoporandroidx.core:core-splashscreen, reteniendo la pantalla de arranque del sistema mientrasEstadoSesionseaComprobando. - Mensajes en recursos. Extrae los textos de error a
strings.xmly haz que el ViewModel exponga el tipo de error en lugar del mensaje ya traducido. - Migración a Proto DataStore. Define un esquema
.protocontokenyexpiraciony sustituye Preferences DataStore, ganando seguridad de tipos en tiempo de compilación. - Pruebas unitarias. Escribe tests de
Jwt.caducidadMs()con tokens de ejemplo (válido, caducado, malformado, sinexp) enapp/src/test. Es una función pura: no necesita dispositivo ni red.
21. Referencias#
- Documentación de la API Coffee — https://api.javiercarrasco.es/coffee/
- Guía de arquitectura de apps de Android — https://developer.android.com/topic/architecture
- Capa de datos — https://developer.android.com/topic/architecture/data-layer
- DataStore — https://developer.android.com/topic/libraries/architecture/datastore
- Notas de versión de androidx.datastore — https://developer.android.com/jetpack/androidx/releases/datastore
- Flujos de datos (Kotlin Flow) en Android — https://developer.android.com/kotlin/flow
stateIny flujos en la interfaz — https://developer.android.com/kotlin/flow/stateflow-and-sharedflow- Estado y Jetpack Compose — https://developer.android.com/develop/ui/compose/state
- Efectos secundarios en Compose (
LaunchedEffect) — https://developer.android.com/develop/ui/compose/side-effects - Navigation Compose con rutas type-safe — https://developer.android.com/guide/navigation/design/type-safety
- Supervisar la conectividad de red — https://developer.android.com/training/monitoring-device-state/connectivity-status-type
- Splash Screen API — https://developer.android.com/develop/ui/views/launch/splash-screen
- Retrofit — https://square.github.io/retrofit/
- OkHttp e interceptores — https://square.github.io/okhttp/features/interceptors/
- RFC 7519, JSON Web Token (JWT) — https://datatracker.ietf.org/doc/html/rfc7519
- Anexo B3-A4 (Room, migraciones y API Key segura)