Saltar al contenido
K
Autenticación con SupabasePaso a paso27 min Avanzado

La sesión en el núcleo

Al terminar: Guardar el estado de sesión en un StateFlow que lea toda la app.

La API ya sabe hablar con Supabase, pero nadie guarda lo que recibe ni sabe quién está dentro. Esta lección construye esa parte: dónde se guardan los tokens, un estado que dice «quién está usando la app» y un repositorio que lo cambia al registrar, entrar y salir. Al terminar tendrás el estado de sesión en un StateFlow que las dos interfaces van a mirar, y todo probado con tests.

Sigue sin haber pantallas. No se instala nada: todo está en shared y los comandos se escriben en la Terminal de Android Studio, en la carpeta de tu proyecto. Son cuatro ficheros nuevos y uno que cambia (Modules.kt); el apartado «El test del orden» (paso 3) añade un quinto, OrdenTest.kt, y una línea en shared/build.gradle.kts, y es parte de la lección: sus números cuentan en el punto de control.

shared
build.gradle.ktsuna línea, en «El test del orden» (paso 3)
src/commonMain/kotlin/com/sebastiannarvaez/pokedex
data/TokenStore.ktse crea (paso 1)
feature/auth/AuthState.ktse crea (paso 1)
data/SessionRepository.ktse crea (paso 2)
di/Modules.ktse modifica (paso 3)
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data
SessionRepositoryTest.ktse crea (paso 4)
src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di
OrdenTest.ktse crea en «El test del orden» (paso 3)
Todo en `shared`. Vista Project.

Cada fichero nuevo se crea igual que en la lección anterior: clic derecho sobre la carpeta shared → New → File y la ruta desde la carpeta del módulo. En todos, primero va la línea package y después los import.

El estado de sesión lo mira toda la app

Antes de escribir, el porqué de la forma. La sesión es estado de toda la app: lo miran la raíz de Android, la raíz de iOS y la pantalla de ajustes, y ninguna lo decide por su cuenta. Es lo mismo que ya viste con el partido en juego de Marcador y con los ajustes de la Pokédex: un único dueño (aquí, SessionRepository, el único que lo cambia) y varias pantallas que lo leen.

Paso 1 · Dónde se guardan los tokens y quién eres

Dónde se guardan los tokens. TokenStore.kt (ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/TokenStore.kt) es una interfaz, porque cada plataforma tiene su sitio y porque un test no debería escribir en el almacén del sistema para comprobar una regla. De momento hay una implementación en memoria:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/TokenStore.kt
package com.sebastiannarvaez.pokedex.data
 
import com.sebastiannarvaez.pokedex.domain.Session
 
// Dónde se guardan los tokens. No lleva `internal`: aquí da igual, porque
// nadie fuera de `shared` la usa; el almacén de verdad cumplirá este mismo
// contrato en una lección posterior.
interface TokenStore {
    suspend fun leer(): Session?
    suspend fun guardar(session: Session)
    suspend fun borrar()
}
 
// Para tests y para arrancar sin nada montado: guarda en una variable, y al
// cerrar la app la sesión se pierde.
class InMemoryTokenStore : TokenStore {
    private var session: Session? = null
    override suspend fun leer(): Session? = session
    override suspend fun guardar(session: Session) { this.session = session }
    override suspend fun borrar() { session = null }
}

Quién eres. AuthState.kt (ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthState.kt) guarda el estado de la sesión: quién está usando la app.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthState.kt
package com.sebastiannarvaez.pokedex.feature.auth
 
import com.sebastiannarvaez.pokedex.domain.Session
 
// Quién está usando la app. Dos campos que dan TRES estados.
data class AuthState(
    // true = «todavía no lo sé»: al arrancar hay que leer el token guardado.
    val comprobando: Boolean = true,
    // La sesión, o null si no hay nadie.
    val session: Session? = null,
) {
    val haySesion: Boolean get() = session != null
}

Dos campos que dan tres estados, y el del medio es el que se olvida. comprobando = true significa «todavía no lo sé»: sin ese estado, la app enseñaría la pantalla de entrada durante un instante a alguien que ya tenía sesión, que es de los parpadeos que más molestan. Un ejemplo ajeno al proyecto, un portero con su lista de invitados:

ejemplo mínimo: tres estados con dos campos (con comentarios)
// Un portero con su lista: «aún no he mirado», «no estás» o «adelante».
data class Portero(val mirando: Boolean = true, val invitado: String? = null)
 
fun saludo(p: Portero): String = when {
    p.mirando -> "Un momento, miro la lista"
    p.invitado == null -> "No estás en la lista"
    else -> "Adelante, ${p.invitado}"
}
 
fun main() {
    println(saludo(Portero()))
    println(saludo(Portero(mirando = false)))
    println(saludo(Portero(mirando = false, invitado = "Ana")))
}

Esto es lo que sale:

Un momento, miro la lista
No estás en la lista
Adelante, Ana

Portero() recién creado tiene mirando = true y todavía no sabe nada; solo cuando termina de mirar hay dos respuestas posibles. AuthState() funciona igual: recién creado, tiene comprobando = true y session = null, y la app no debe enseñar ni la pantalla de entrada ni la lista, sino una espera.

Paso 2 · El repositorio: una sola fuente de verdad

El repositorio. SessionRepository.kt (ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt) publica ese estado y lo cambia: restaurar, registrar, entrar y salir.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepository.kt
package com.sebastiannarvaez.pokedex.data
 
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.network.SupabaseAuthApi
import com.sebastiannarvaez.pokedex.data.network.dto.SessionDto
import com.sebastiannarvaez.pokedex.domain.Session
import com.sebastiannarvaez.pokedex.feature.auth.AuthState
import kotlin.time.Clock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
 
// La sesión, como única fuente de verdad: quien quiera saber si hay usuario
// mira `state`.
internal class SessionRepository(
    private val api: SupabaseAuthApi,
    private val store: TokenStore,
    private val dispatchers: AppDispatchers,
    // El reloj entra por parámetro, con un valor por defecto: así un test lo
    // congela y comprueba la caducidad sin esperar. `Clock.System.now()` es el
    // instante actual y `.epochSeconds`, los segundos que han pasado desde 1970.
    private val ahoraEnSegundos: () -> Long = { Clock.System.now().epochSeconds },
) {
 
    // `_state` se escribe aquí; `state` es la misma cosa en solo lectura.
    private val _state = MutableStateFlow(AuthState())
    val state: StateFlow<AuthState> = _state.asStateFlow()
 
    // Al arrancar: lee lo guardado y deja de «comprobar».
    suspend fun restaurar() = withContext(dispatchers.io) {
        val guardada = store.leer()
        _state.value = AuthState(comprobando = false, session = guardada)
    }
 
    suspend fun registrar(email: String, password: String) = withContext(dispatchers.io) {
        publicar(api.registrar(email.trim(), password))
    }
 
    suspend fun entrar(email: String, password: String) = withContext(dispatchers.io) {
        publicar(api.entrar(email.trim(), password))
    }
 
    suspend fun salir() = withContext(dispatchers.io) {
        // Avisa al servidor (si falla, `salir` ya se lo traga) y borra lo local.
        _state.value.session?.let { api.salir(it.accessToken) }
        store.borrar()
        _state.value = AuthState(comprobando = false, session = null)
    }
 
    // Convierte el DTO en sesión de dominio, la guarda y la publica.
    private suspend fun publicar(dto: SessionDto): Session {
        val session = Session(
            accessToken = dto.accessToken,
            refreshToken = dto.refreshToken,
            // Se guarda el instante de caducidad, no la duración: `expires_in`
            // solo es cierto en el momento en que llega.
            expiresAtEpochSeconds = ahoraEnSegundos() + dto.expiresIn,
            // `.orEmpty()`: el texto, o uno vacío si era `null`.
            userId = dto.user?.id.orEmpty(),
            email = dto.user?.email,
        )
        store.guardar(session)
        _state.value = AuthState(comprobando = false, session = session)
        return session
    }
}

email.trim() quita los espacios que el teclado móvil deja al final al autocompletar. Y esta clase todavía no renueva el token cuando caduca: eso es Refrescar el token sin duplicados.

Paso 3 · Al grafo

Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt (Koin: un solo grafo en commonMain). Son tres cambios, y el orden del segundo importa.

Primero, estos import junto a los demás:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: imports nuevos
import com.sebastiannarvaez.pokedex.data.InMemoryTokenStore
import com.sebastiannarvaez.pokedex.data.SessionRepository
import com.sebastiannarvaez.pokedex.data.TokenStore
import com.sebastiannarvaez.pokedex.data.network.KtorSupabaseAuthApi
import com.sebastiannarvaez.pokedex.data.network.SupabaseAuthApi
import com.sebastiannarvaez.pokedex.data.network.createSupabaseClient
import org.koin.core.qualifier.named

Segundo, el nombre del cliente de Supabase. Una línea nueva encima del comentario /** El grafo que comparten las dos apps … */ que precede a val pokedexModule (así el comentario sigue pegado a pokedexModule):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: encima de pokedexModule
// El nombre que distingue al cliente de Supabase del de PokeAPI.
val SUPABASE = named("supabase")

named("supabase") crea una etiqueta. Hay dos HttpClient en el grafo, y Koin identifica cada pieza por su tipo: con dos del mismo tipo y sin etiqueta, el segundo sustituye al primero sin avisar (el de PokeAPI acabaría apuntando a Supabase). Con etiqueta, se registra con single(SUPABASE) { … } y se pide con get(SUPABASE).

Tercero, dentro de pokedexModule, debajo de single { SettingsRepository(get()) } (o la última definición de repositorio que tengas):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt: dentro de pokedexModule
    // El cliente de Supabase lleva nombre: hay dos HttpClient en el grafo y,
    // sin nombre, el segundo sustituye al primero sin avisar.
    single(SUPABASE) { createSupabaseClient(get()) }
    single<SupabaseAuthApi> { KtorSupabaseAuthApi(get(SUPABASE)) }
    // De momento en memoria: al cerrar la app la sesión se pierde, y se
    // arregla con el almacenamiento de verdad.
    single<TokenStore> { InMemoryTokenStore() }
    single { SessionRepository(get(), get(), get()) }

El test del orden de las val

Este test no es opcional: es el único que vigila el orden de arriba, porque ModulesTest no lo ve. Son cinco pasos, y los números del punto de control ya cuentan con él.

1. Un motor de red para la JVM. Hace falta un test que arranque el grafo y pida el cliente (vigilará el orden en esta lección, y El cliente que lleva tu sesión lo amplía dos lecciones más adelante). Para crear un HttpClient en la JVM, Ktor necesita un motor de red: la pieza que de verdad envía los datos por internet. La JVM no trae ninguno; Android usa OkHttp, y aquí se le presta el mismo solo para los tests. Abre shared/build.gradle.kts, busca jvmTest.dependencies, añade una línea y pulsa Sync Now:

shared/build.gradle.kts: una línea en jvmTest.dependencies
            implementation(libs.ktor.client.okhttp)

2. El test. Crea src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/OrdenTest.kt (clic derecho sobre shared → New → File). Antes de leerlo, unas piezas nuevas: startKoin { … } arranca Koin con los módulos que le des; stopKoin() lo para; y assertNotSame comprueba que dos referencias son objetos distintos. try { … } finally { … } ejecuta el bloque de finally pase lo que pase: aunque el test falle, Koin se para y el siguiente test arranca limpio.

shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/OrdenTest.kt
package com.sebastiannarvaez.pokedex.di
 
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.core.BaseAppConfig
import io.ktor.client.HttpClient
import kotlin.test.Test
import kotlin.test.assertNotSame
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
 
class OrdenTest {
    @Test
    fun elClienteDeSupabaseNoPisaAlDePokeApi() {
        // `AppConfig` lo pone cada app al arrancar: aquí, una de mentira.
        val config = object : BaseAppConfig() {
            override val supabaseUrl = "https://proyecto.supabase.co"
            override val supabaseKey = "clave-publica"
        }
        // `platformModule` es el módulo de las piezas propias de cada plataforma
        // (en la JVM, uno de pruebas). `.koin` saca de lo que devuelve
        // `startKoin` el contenedor desde el que se piden piezas con `get`.
        val koin = startKoin {
            modules(pokedexModule, platformModule, module { single<AppConfig> { config } })
        }.koin
        try {
            assertNotSame(koin.get<HttpClient>(), koin.get<HttpClient>(SUPABASE))
        } finally {
            stopKoin()
        }
    }
}

3. Lánzalo. ./gradlew :shared:jvmTest --tests '*OrdenTest*' (los * valen por «lo que sea»: el filtro coge cualquier test cuyo nombre contenga OrdenTest). Pasa.

4. Rómpelo a propósito. Mueve la línea val SUPABASE = named("supabase") (con su comentario) al final del fichero Modules.kt y vuelve a lanzarlo. Falla:

NoDefinitionFoundException: No definition found for type 'io.ktor.client.HttpClient'
and qualifier 'supabase' on scope '['_root_']'.

ModulesTest (el test del grafo que escribiste en Koin: un solo grafo en commonMain; sale en el informe de la JVM) sigue en verde en ese estado: por eso hace falta OrdenTest.

5. Devuelve la línea a su sitio, encima de pokedexModule, y vuelve a lanzar OrdenTest: en verde. Déjalo puesto.

Paso 4 · Los tests del repositorio

Son los que prueban la regla central. SessionRepositoryTest.kt (ruta src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.kt). Lleva un servidor de mentira, FakeAuthApi, que devuelve sesiones fijas y cuenta cuántas veces le piden cerrar sesión. No hace falta ConfigDePrueba: el repositorio no lee la configuración. Lo que sí necesita es un dispatcher de pruebas.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/SessionRepositoryTest.kt
package com.sebastiannarvaez.pokedex.data
 
import com.sebastiannarvaez.pokedex.data.network.SupabaseAuthApi
import com.sebastiannarvaez.pokedex.data.network.dto.SessionDto
import com.sebastiannarvaez.pokedex.data.network.dto.UserDto
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import com.sebastiannarvaez.pokedex.domain.Session
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
 
// El servidor de mentira: contesta sesiones fijas y cuenta los cierres.
private class FakeAuthApi(private val expiresIn: Long = 3600) : SupabaseAuthApi {
    var cierresEnElServidor = 0
        private set
 
    override suspend fun registrar(email: String, password: String) = sesion("registro")
    override suspend fun entrar(email: String, password: String) = sesion("entrada")
    override suspend fun refrescar(refreshToken: String) = sesion("refresco")
    override suspend fun salir(accessToken: String) { cierresEnElServidor++ }
 
    private fun sesion(marca: String) = SessionDto(
        accessToken = "access-$marca",
        refreshToken = "refresh-$marca",
        expiresIn = expiresIn,
        user = UserDto(id = "u-1", email = "ash@pueblo-paleta.test"),
    )
}
 
class SessionRepositoryTest {
 
    // Una función de extensión de `TestScope` (se escribe fuera de esa clase y
    // se usa como si fuera suya) a propósito: así el dispatcher comparte el
    // planificador del test (`testScheduler`).
    private fun TestScope.repo(
        api: SupabaseAuthApi = FakeAuthApi(),
        store: TokenStore = InMemoryTokenStore(),
        ahora: () -> Long = { 1_000 },
    ) = SessionRepository(api, store, TestDispatchers(StandardTestDispatcher(testScheduler)), ahora)
 
    @Test
    fun `al arrancar sin nada guardado no hay sesion`() = runTest {
        val r = repo()
 
        assertTrue(r.state.value.comprobando)
        r.restaurar()
 
        assertEquals(false, r.state.value.comprobando)
        assertNull(r.state.value.session)
    }
 
    @Test
    fun `restaurar recupera lo que habia guardado`() = runTest {
        val store = InMemoryTokenStore()
        store.guardar(Session("a", "r", 99_999, "u-1", "ash@pueblo-paleta.test"))
 
        val r = repo(store = store)
        r.restaurar()
 
        assertEquals("a", r.state.value.session?.accessToken)
        assertTrue(r.state.value.haySesion)
    }
 
    // Lo importante no es el token, es la fecha: el servidor manda `expires_in`
    // (3600) y aquí se convierte en un instante (1000 + 3600).
    @Test
    fun `entrar convierte expires_in en instante de caducidad`() = runTest {
        val r = repo(ahora = { 1_000 })
 
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        assertEquals(4_600, r.state.value.session?.expiresAtEpochSeconds)
    }
 
    @Test
    fun `entrar deja la sesion guardada para el proximo arranque`() = runTest {
        val store = InMemoryTokenStore()
        repo(store = store).entrar("ash@pueblo-paleta.test", "pikachu")
 
        assertEquals("access-entrada", store.leer()?.accessToken)
    }
 
    @Test
    fun `salir borra el token y avisa al servidor`() = runTest {
        val api = FakeAuthApi()
        val store = InMemoryTokenStore()
        val r = repo(api, store)
        r.entrar("ash@pueblo-paleta.test", "pikachu")
 
        r.salir()
 
        assertNull(r.state.value.session)
        assertNull(store.leer())
        assertEquals(1, api.cierresEnElServidor)
    }
}

TestDispatchers es el doble de Dobles.kt que ya usas desde Probar el puente en iOS, y FakeAuthApi se queda aquí por ahora porque solo lo usa este test.

Lanza las tres tareas de tests, sin filtro, y abre los informes. iosSimulatorArm64Test solo corre en un Mac:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test
open shared/build/reports/tests/jvmTest/index.html
open shared/build/reports/tests/testAndroidHostTest/index.html
open shared/build/reports/tests/iosSimulatorArm64Test/index.html

Salen 93 en la JVM, 56 en Android host y 59 en iOS: los 87 / 51 / 54 que traías de la lección anterior, más los cinco de SessionRepositoryTest en las tres plataformas, más OrdenTest, que solo corre en la JVM.

Punto de control

  • En shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ están data/TokenStore.kt, feature/auth/AuthState.kt y data/SessionRepository.kt, y di/Modules.kt registra el cliente con nombre, la API, el almacén en memoria y el repositorio.
  • En Modules.kt, val SUPABASE = named("supabase") está encima de val pokedexModule.
  • Las tres tareas sin filtro terminan en BUILD SUCCESSFUL, y los recuadros tests de los informes dan 93 en la JVM, 56 en Android host y 59 en iOS. ModulesTest sigue en verde con las definiciones nuevas.
  • El estado arranca con comprobando = true y pasa a false después de restaurar() (lo comprueba el primer test).
  • La app compila y arranca igual que antes: todavía no hay pantallas de sesión.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.AuthState() recién creado tiene comprobando = true y session = null. ¿Qué debe enseñar la app en ese momento?

  2. 2.¿Por qué solo SessionRepository escribe en _state, y el resto de la app lee state?

  3. 3.En Modules.kt, ¿por qué val SUPABASE = named("supabase") va encima de val pokedexModule y no debajo?

  4. 4.¿Por qué OrdenTest llama a stopKoin() dentro de un finally?

  5. 5.Con el reloj de un test en 1000 y un servidor que manda expires_in = 3600, ¿qué guarda Session en expiresAtEpochSeconds?

  6. 6.Si el ayudante repo del test creara el dispatcher con StandardTestDispatcher() a secas, ¿qué pasaría?

Faltan 6 de 6