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:

  1. Un formulario de login que envía usuario y contraseña a la API Coffee.
  2. Control de errores: credenciales incorrectas, datos incompletos, error del servidor y ausencia de red.
  3. Detección del estado de la conexión antes y durante el intento de login.
  4. Una pantalla que muestra el token recibido y su fecha de caducidad.
  5. Persistencia del token con DataStore Preferences, de forma que al cerrar y volver a abrir la aplicación la sesión siga activa.
  6. Comprobación de la caducidad del token dentro de la propia aplicación.
  7. 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 HttpLoggingInterceptor en nivel BODY (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 a null, 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 iat y el instante de caducidad exp, 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  ↔  Frameworks

Aplicado 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 AppContainer que vive en la clase Application.


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-preferences arrastra 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 a T. Si la función declara el tipo del cuerpo directamente (suspend fun login(...): LoginResponseDto), Retrofit lanza una HttpException ante cualquier código distinto de 2xx, y el control de errores se convierte en un try/catch con e.code(). Declarando Response<T> el resultado siempre llega como valor y podemos usar isSuccessful y code(), 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 combina baseUrl con 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 a https://api.javiercarrasco.es/login.

Cuidado con el nivel BODY en 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ón BuildConfig.DEBUG garantiza 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 funciones suspend generadas 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 un withContext serí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 SharedPreferences y 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 del Flow. 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 preferencesDataStore a 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 con applicationContext. Pasarle el contexto de una Activity retendría esa Activity mientras viva el DataStore y provocaría una fuga de memoria.

8.3. Limitaciones de seguridad#

DataStore tampoco cifra el contenido. El fichero .preferences_pb es 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 que androidx.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 401 como 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 map no borra el token caducado. Sería tentador llamar a local.borrarSesion() dentro del map al 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 el TokenViewModel (apartado 16), que es quien detecta el vencimiento.

El repositorio no expone Flow<String> con el token, sino Flow<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:name en el manifiesto, Android instancia la clase Application por defecto y el cast application as ExampleLoginApplication lanza una ClassCastException al 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_INTERNET frente a NET_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}

stateIn convierte un Flow frío en un StateFlow caliente. El Flow de DataStore es frío: cada recolector abriría su propia lectura del fichero. stateIn lo comparte entre todos los recolectores, guarda el último valor emitido y proporciona el initialValue que 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, SesionViewModel traduce, 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, el Flow de 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 con stringResource(). 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 no collectAsState(). La primera detiene la recolección cuando la pantalla pasa a segundo plano (STOPPED) y la reanuda al volver. Con collectAsState() el flujo se seguiría recolectando con la aplicación en segundo plano, consumiendo recursos innecesariamente. Requiere lifecycle-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}

collectLatest frente a collect. collectLatest cancela 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 el delay hasta 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 RutaToken

17.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. LaunchedEffect se 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 con splashScreen.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.kt

18.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 RutaToken

Y 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 de SharedPreferences, 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 margenMs de Jwt.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 en SesionLocalDataSource que guarde un JWT de prueba ya caducado.


20. Ampliaciones propuestas#

  1. Interceptor de autorización. Añade un Interceptor de OkHttp que inserte la cabecera Authorization: Bearer <token> en todas las peticiones, y consume GET /coffee para listar los cafés en una tercera pantalla. Ojo: el interceptor es síncrono y el token vive en un Flow; habrá que usar runBlocking { 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.
  2. 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.
  3. Splash Screen API. Sustituye PantallaCargando por androidx.core:core-splashscreen, reteniendo la pantalla de arranque del sistema mientras EstadoSesion sea Comprobando.
  4. Mensajes en recursos. Extrae los textos de error a strings.xml y haz que el ViewModel exponga el tipo de error en lugar del mensaje ya traducido.
  5. Migración a Proto DataStore. Define un esquema .proto con token y expiracion y sustituye Preferences DataStore, ganando seguridad de tipos en tiempo de compilación.
  6. Pruebas unitarias. Escribe tests de Jwt.caducidadMs() con tokens de ejemplo (válido, caducado, malformado, sin exp) en app/src/test. Es una función pura: no necesita dispositivo ni red.

21. Referencias#

Calendar  Última modificación: viernes, 21 de agosto de 2026