El enlace del correo y la contraseña nueva
Al terminar: Abrir el enlace del correo, que no es una pantalla, y cambiar la contraseña desde dentro.
El correo de recuperación ya sale. Falta lo que pasa cuando el usuario pulsa el enlace que trae: la app se abre, entra con una sesión temporal y le deja poner una contraseña nueva. Aparece además un enlace entrante que no es una pantalla, y eso obliga a decidir dónde vive algo que no encaja en el mapa de navegación.
Al terminar, abrir un enlace de recuperación (el de un correo de verdad, o uno de mentira lanzado desde la Terminal) deja un diálogo de contraseña nueva encima de la app en Android y una hoja en iOS. Cambiarla funciona. No se instala nada.
Toca el núcleo (pasos 1 y 2) y las dos apps (pasos 3 y 4); el paso 5 lo prueba.
Paso 1 · El enlace que no es una pantalla
Aquí está la decisión de diseño de la lección. El correo trae un enlace con la forma pokedex://auth/callback#…, que cae en el mismo esquema (pokedex://) que los enlaces de Deep links en Android. Pero no es una pantalla que el usuario pide: es un suceso de sesión que además trae credenciales. Meterlo en Destination obligaría a que cada when de navegación tratara un caso que no navega a ningún sitio. Vive en la feature de sesión, aparte.
El concepto. Un enlace puede llevar datos en dos sitios: en la consulta (lo que va detrás del ?) y en el fragmento (lo que va detrás del #). Se parecen, pero se comportan distinto: el fragmento no se envía al servidor en una petición HTTP. Supabase pone aquí los tokens a propósito: si fueran en la consulta, quedarían en el registro de cualquier servidor por el que pasara el enlace. Un enlace real tiene esta forma:
pokedex://auth/callback#access_token=eyJhbGci.abc&expires_in=3600&refresh_token=v1kqNrTz&type=recoveryPara sacar los datos hay que cortar ese texto: por los &, en trozos, y cada trozo por el =. Primero, la técnica con datos ajenos al proyecto: leer el pedido de una heladería. Es para escribirlo en el Playground de Kotlin (play.kotlinlang.org), no en el proyecto.
fun main() {
val texto = "sabor=fresa&bolas=2&=sin-clave&cono"
val campos = texto.split('&') // ["sabor=fresa", "bolas=2", "=sin-clave", "cono"]
.mapNotNull { trozo -> // como map, pero tira los null
val clave = trozo.substringBefore('=') // lo de antes del =
val valor = trozo.substringAfter('=', missingDelimiterValue = "") // lo de después; "" si no hay =
if (clave.isBlank() || valor.isBlank()) null else clave to valor
}
.toMap() // lista de parejas -> mapa
println(campos)
println(campos["bolas"])
println(campos["sirope"])
}Sale {sabor=fresa, bolas=2}, luego 2, luego null. Pruébalo y cámbialo: añade &sirope=chocolate al texto y vuelve a ejecutar; el mapa ganará una tercera pareja y campos["sirope"] ya no imprimirá null. Las piezas nuevas, una a una:
split('&')corta el texto por cada&y devuelve una lista de trozos.mapNotNull { … }transforma cada elemento de la lista (como elmapque viste en Lambdas con listas: filter, map y reglas) y tira los resultadosnull. Aquí"=sin-clave"no tiene clave y"cono"no tiene valor: los dos devuelvennully desaparecen.clave to valorcrea una pareja. Una lista de parejas es lo que necesitatoMap()para construir un mapa: una tabla de «clave → valor» donde se busca por la clave.campos["bolas"]busca por clave y devuelve el valor, onullsi esa clave no está (por esocampos["sirope"]imprimenull).
El archivo. EnlaceDeRecuperacion.kt, en shared, paquete feature.auth. Créalo con clic derecho sobre shared → New → File y la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/EnlaceDeRecuperacion.kt. Es una data class con los tres datos del enlace y una función parse que los lee de la URL: lo mismo que el ejemplo, pero con tres comprobaciones más.
package com.sebastiannarvaez.pokedex.feature.auth
// El enlace que llega por correo para recuperar la contraseña.
// NO es un destino de la app y por eso no está en `Destination`: aquel mapa son
// pantallas que el usuario puede pedir; esto es un suceso de sesión que además
// trae credenciales.
data class EnlaceDeRecuperacion(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long,
) {
companion object {
// La ruta a la que Supabase redirige. Va en `redirect_to`.
const val RUTA = "auth/callback"
// Devuelve el enlace, o null si la URL no es de recuperación.
fun parse(url: String): EnlaceDeRecuperacion? {
// Una URL de otro sitio de la app (pokedex://pokemon/25) no es esto.
if (!url.contains(RUTA)) return null
// Los tokens vienen detrás del `#` (el fragmento), no del `?`.
val fragmento = url.substringAfter('#', missingDelimiterValue = "")
if (fragmento.isBlank()) return null
// «a=1&b=2» -> mapa {a: 1, b: 2}, como en el ejemplo. Los trozos
// sin clave o sin valor se descartan.
val campos = fragmento.split('&')
.mapNotNull { trozo ->
val clave = trozo.substringBefore('=')
val valor = trozo.substringAfter('=', missingDelimiterValue = "")
if (clave.isBlank() || valor.isBlank()) null else clave to valor
}
.toMap()
// `type=recovery` distingue este correo del de confirmación de
// cuenta, que llega al mismo sitio con la misma forma.
if (campos["type"] != "recovery") return null
// Si falta cualquiera de los dos tokens, el enlace se descarta
// entero: media sesión no sirve de nada.
val acceso = campos["access_token"] ?: return null
val refresco = campos["refresh_token"] ?: return null
return EnlaceDeRecuperacion(
accessToken = acceso,
refreshToken = refresco,
// Si no dice cuánto dura, se asume una hora.
expiresIn = campos["expires_in"]?.toLongOrNull() ?: 3600,
)
}
}
}expiresIn = campos["expires_in"]?.toLongOrNull() ?: 3600: el valor del mapa es un texto ("3600"), y toLongOrNull() lo convierte en número, o devuelve null si no es un número; el ?: pone 3600 (una hora, en segundos) cuando es null o la clave no existe.
companion object es el sitio de la clase para lo que no pertenece a un objeto concreto: EnlaceDeRecuperacion.RUTA y EnlaceDeRecuperacion.parse(…) se llaman sobre la clase, sin crear ningún enlace (object, companion y vararg). ?: return null es el operador de «si es null, sal de la función devolviendo null» (Nulabilidad).
La comprobación del type parece de más y no lo es: sin ella, confirmar un registro abriría la pantalla de contraseña nueva a alguien que no la había pedido. Es una regla general: cuando dos cosas distintas llegan por el mismo canal con la misma forma, el campo que las distingue no es opcional, aunque el compilador no te obligue a mirarlo. Un test lo comprueba, más abajo.
La ruta, con nombre. Ahora que existe RUTA, la dirección que escribiste a mano en AuthViewModel puede usarla: el texto auth/callback deja de estar repetido en dos sitios. En AuthViewModel.kt:
// Antes:
private companion object {
val REDIRECCION = "${Destination.ESQUEMA}://auth/callback"
}
// Después:
private companion object {
val REDIRECCION = "${Destination.ESQUEMA}://${EnlaceDeRecuperacion.RUTA}"
}El test. Seis tests comprueban que parse saca los tokens del fragmento y descarta lo que no es de recuperación (entre ellos, el del type=signup). Repiten el patrón de los tests de Testing del núcleo compartido y no aportan nada nuevo, así que se copian: clic derecho sobre shared → New → File, ruta src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/auth/EnlaceDeRecuperacionTest.kt, Copiar fichero, seleccionar todo (Cmd+A) y pegar (Cmd+V).
El enlace trae sesión. Esta parte sorprende la primera vez: el correo de recuperación te mete dentro. Tiene sentido: quien ha olvidado la contraseña no puede demostrar quién es con la contraseña. El enlace del correo es la prueba, y lo que da es una sesión temporal con la que cambiarla. Por eso adoptarla entra por la misma puerta que un login. En SessionRepository.kt añade el import de EnlaceDeRecuperacion arriba y esta función junto a la de recuperar:
// Import nuevo, arriba del fichero:
import com.sebastiannarvaez.pokedex.feature.auth.EnlaceDeRecuperacion
// Dentro de la clase, debajo de `recuperar`:
// Adopta la sesión que trae el enlace del correo. El enlace de recuperación
// YA viene con sesión iniciada: Supabase da acceso temporal para que el
// usuario pueda cambiar la contraseña sin saberse la vieja. Por eso esto
// entra por la misma puerta que un login: guarda y publica.
// `: Unit` dice que no devuelve nada: la función vale lo que vale el bloque
// de `withContext`, y su última línea (una asignación) no es un valor.
suspend fun adoptar(enlace: EnlaceDeRecuperacion): Unit = withContext(dispatchers.io) {
val session = Session(
accessToken = enlace.accessToken,
refreshToken = enlace.refreshToken,
// El instante de caducidad, no la duración (como en publicar).
expiresAtEpochSeconds = ahoraEnSegundos() + enlace.expiresIn,
// El enlace no trae datos del usuario, así que el identificador y
// el correo se quedan vacíos: bastan para cambiar la contraseña, y
// las pantallas de cuenta enseñan un texto genérico en vez del
// correo hasta que el usuario vuelve a entrar.
userId = "",
email = null,
)
store.guardar(session)
_state.value = AuthState(comprobando = false, session = session)
}Y en AuthViewModel.kt, la función que usará la raíz de la app para abrir el enlace, encima de private companion object:
// El enlace del correo trae sesión temporal: se adopta y se entra.
fun abrirRecuperacion(enlace: EnlaceDeRecuperacion) {
viewModelScope.launch { repository.adoptar(enlace) }
}Los tests de adoptar y de la dirección de recuperación llegan con el primer fichero de tests de la cuenta, en Eliminar la cuenta (paso 3): aquí aún no existe un fichero de tests de AccountRepository en el que agruparlos.
Paso 2 · Cambiar la contraseña, ya dentro
El enlace deja al usuario dentro con una sesión temporal. Lo que hace falta ahora es poder cambiar la contraseña, y eso ya no es «sin sesión»: va con el cliente que lleva tu sesión, el de El cliente que lleva tu sesión.
La API. En SupabaseAccountApi.kt, la interfaz gana la operación y la clase la implementa con un PUT a user, con la contraseña nueva como cuerpo. PUT es el verbo HTTP de «sustituye esto por lo que te mando»: aquí, la contraseña del usuario. El cliente ya pone la cabecera Authorization con el token del usuario. Imports nuevos arriba (body ya está importado desde perfil; Option+Enter sobre cada nombre en rojo los añade, y esta es la lista por si dudas):
import com.sebastiannarvaez.pokedex.data.network.dto.AuthErrorDto
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import kotlinx.serialization.Serializable// En la interfaz SupabaseAccountApi, debajo de `perfil`:
suspend fun cambiarPassword(nueva: String)
// Encima de la clase KtorSupabaseAccountApi:
// El cuerpo de «cambiar contraseña»: solo la nueva.
@Serializable
private data class PasswordDto(val password: String)
// Dentro de KtorSupabaseAccountApi, debajo de `perfil`:
// PUT /auth/v1/user con la contraseña nueva. El cliente ya pone la cabecera
// Authorization con el token del usuario.
override suspend fun cambiarPassword(nueva: String) {
client.put("user") {
contentType(ContentType.Application.Json)
setBody(PasswordDto(nueva))
}.exigirExito()
}
// Si el servidor no responde con éxito, lanza AuthException con su mensaje.
// Es la tercera copia de «lee AuthErrorDto si no es éxito» (ver el paso 1
// de la lección anterior); aquí va en una función porque la lección
// siguiente la usa otra vez.
private suspend fun HttpResponse.exigirExito() {
if (status.isSuccess()) return
val error = runCatching { body<AuthErrorDto>() }.getOrNull()
throw AuthException(status.value, error?.mensaje)
}HttpResponse.exigirExito() es una función de extensión privada, como la sesionOError que escribiste en Autenticación en el núcleo: la API: se llama sobre una respuesta como si fuera un método suyo (Extensiones).
El repositorio. AccountRepository.kt es nuevo: lo que se hace con la cuenta, ya dentro. Está separado de SessionRepository por el mismo motivo que existe el tercer cliente HTTP de la app, el que lleva tu sesión (los otros dos son el de PokeAPI y el de Supabase sin sesión): ese cliente necesita tokens y los tokens los da la sesión, así que la sesión no puede depender de él. AccountRepository sí puede depender de la sesión (en la lección siguiente la necesitará para cerrarla al borrar la cuenta), porque nadie depende de él. Clic derecho sobre shared → New → File, ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/AccountRepository.kt:
package com.sebastiannarvaez.pokedex.data
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.network.SupabaseAccountApi
import kotlinx.coroutines.withContext
// Lo que se hace CON la cuenta, ya dentro.
internal class AccountRepository(
private val api: SupabaseAccountApi,
private val dispatchers: AppDispatchers,
) {
// `withContext(dispatchers.io)`: la petición va al hilo de entrada y salida.
suspend fun cambiarPassword(nueva: String) = withContext(dispatchers.io) {
api.cambiarPassword(nueva)
}
}El ViewModel. AccountViewModel.kt es otro fichero nuevo, en feature/auth. Es aparte de AuthViewModel porque vive en otro sitio de la app (los ajustes y el diálogo de contraseña nueva) y porque nada de esto tiene sentido sin sesión: juntarlos daría un ViewModel con dos mitades que nunca se usan a la vez. Ruta: src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AccountViewModel.kt.
package com.sebastiannarvaez.pokedex.feature.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.core.UiError
import com.sebastiannarvaez.pokedex.data.AccountRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
// Lo que pasa DENTRO de la cuenta (por ahora, cambiar la contraseña).
data class AccountState(
// true mientras hay una petición en vuelo.
val trabajando: Boolean = false,
val error: UiError? = null,
// true cuando la contraseña se ha cambiado: la pantalla enseña «Listo».
val passwordCambiada: Boolean = false,
)
class AccountViewModel internal constructor(
private val repository: AccountRepository,
) : ViewModel() {
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
fun cambiarPassword(nueva: String) {
// Mismas guardas que el formulario: mínimo de caracteres y una sola
// petición a la vez.
if (nueva.length < AuthFormState.MINIMO_PASSWORD || _state.value.trabajando) return
_state.value = AccountState(trabajando = true)
viewModelScope.launch {
// Otro `try` como expresión, igual que en `recuperar()`: su valor
// es el nuevo estado.
_state.value = try {
repository.cambiarPassword(nueva)
AccountState(passwordCambiada = true)
} catch (e: Throwable) {
AccountState(error = e.aUiErrorDeAuth())
}
}
}
fun descartarError() {
_state.value = _state.value.copy(error = null)
}
// Vuelve al estado del principio: lo llama el diálogo de contraseña nueva
// al cerrarse. El ViewModel vive lo que la actividad, y sin esto el
// «Contraseña cambiada» de ahora seguiría puesto la próxima vez.
fun reiniciar() {
_state.value = AccountState()
}
}Koin. En Modules.kt, añade los import de AccountRepository y AccountViewModel y las dos líneas de registro, el repositorio debajo del de SupabaseAccountApi y el ViewModel junto a los demás:
// Imports nuevos, arriba:
import com.sebastiannarvaez.pokedex.data.AccountRepository
import com.sebastiannarvaez.pokedex.feature.auth.AccountViewModel
// Dentro del módulo:
single { AccountRepository(get(), get()) }
viewModelOf(::AccountViewModel)Ejecuta ./gradlew :shared:jvmTest en la Terminal de Android Studio (en la carpeta de tu proyecto). Deben pasar los seis tests de EnlaceDeRecuperacionTest, además de todos los anteriores.
Paso 3 · La contraseña nueva, en Android
Tres piezas, de dentro afuera: el diálogo, el guardián que lo abre y la actividad que recibe el enlace.
El diálogo de contraseña nueva. NuevaPasswordDialog.kt es un AlertDialog (el de la lección anterior) con un campo y dos botones. Es un diálogo y no una pantalla porque el usuario ya está dentro: el enlace trae una sesión temporal, y sacarlo a una pantalla completa dejaría la app detrás a medio camino entre fuera y dentro. Clic derecho sobre androidApp → New → File y la ruta src/main/kotlin/com/sebastiannarvaez/pokedex/android/auth/NuevaPasswordDialog.kt; después escribe el fichero entero del bloque de abajo, con su línea package (si el fichero ya trae una línea, selecciona todo con Cmd+A y sustitúyela: el bloque pone la suya).
Cuatro cosas que mirar en él, porque son la lógica y no solo la maquetación:
cerrares una lambda que primero llama aviewModel.reiniciar()y luego aalTerminar(). Se usa en tres salidas: tocar fuera, «Ahora no» y «Listo». Sinreiniciar(), el estado «contraseña cambiada» se quedaría puesto: elAccountViewModelde este diálogo vive lo que la actividad (la hoja de cuenta de Ajustes usa otra instancia, como viste en La puerta y la cuenta en Android), y un segundo enlace en la misma sesión de la app abriría directamente «Contraseña cambiada».if (estado.passwordCambiada), al principio, cambia el diálogo entero por el de «Contraseña cambiada» (y elreturnimpide dibujar los dos).- «Guardar» está desactivado mientras la contraseña tenga menos de
AuthFormState.MINIMO_PASSWORDcaracteres o haya una petición en vuelo. isErrormás el texto rojo enseñan el error del servidor sin cerrar el diálogo.
El campo usa lo que ya viste en El formulario de entrada en Android: OutlinedTextField, PasswordVisualTransformation (los puntos en lugar de las letras), supportingText e isError.
package com.sebastiannarvaez.pokedex.android.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.auth.AccountViewModel
import com.sebastiannarvaez.pokedex.feature.auth.AuthFormState
import org.koin.compose.viewmodel.koinViewModel
// La contraseña nueva, tras abrir el enlace del correo.
@Composable
fun NuevaPasswordDialog(
alTerminar: () -> Unit,
viewModel: AccountViewModel = koinViewModel(),
) {
// El texto se queda en `remember`: perder una contraseña a medio escribir al
// girar es aceptable, y así no se guarda en el estado de la actividad.
var password by remember { mutableStateOf("") }
val estado by viewModel.state.collectAsStateWithLifecycle()
// Las tres salidas hacen lo mismo: olvidar el estado y avisar a quien
// abrió el diálogo.
val cerrar = {
viewModel.reiniciar()
alTerminar()
}
if (estado.passwordCambiada) {
AlertDialog(
onDismissRequest = cerrar,
confirmButton = { TextButton(onClick = cerrar) { Text("Listo") } },
title = { Text("Contraseña cambiada") },
text = { Text("Ya puedes seguir usando la app con la contraseña nueva.") },
)
return
}
AlertDialog(
onDismissRequest = cerrar,
title = { Text("Nueva contraseña") },
text = {
Column {
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
supportingText = { Text("Al menos ${AuthFormState.MINIMO_PASSWORD} caracteres") },
isError = estado.error != null,
modifier = Modifier.fillMaxWidth(),
)
estado.error?.let {
Text(
it.detalle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(
onClick = { viewModel.cambiarPassword(password) },
enabled = password.length >= AuthFormState.MINIMO_PASSWORD && !estado.trabajando,
) { Text("Guardar") }
},
dismissButton = { TextButton(onClick = cerrar) { Text("Ahora no") } },
)
}El guardián recibe el enlace. Raiz.kt es tuyo (lo escribiste en La puerta y la cuenta en Android) y recibe dos parámetros nuevos. Antes de los cambios, una pieza:
Cuatro cambios, en este orden:
// 1. Imports nuevos, arriba:
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.sebastiannarvaez.pokedex.feature.auth.EnlaceDeRecuperacion
// 2. La firma, con los parámetros nuevos antes de `contenido`:
@Composable
fun RaizConSesion(
// El enlace del correo, si llegó uno. Lo guarda la actividad.
recuperacion: EnlaceDeRecuperacion? = null,
// Lo que se llama al usarlo, para que la actividad lo ponga a null.
alConsumirRecuperacion: () -> Unit = {},
contenido: @Composable () -> Unit,
) {
// 3. Debajo de `val estado by viewModel.state.collectAsStateWithLifecycle()`:
// Si el diálogo de contraseña nueva está abierto. `rememberSaveable` y no
// `remember`: al girar el móvil Android rehace la actividad, el enlace ya
// se consumió y no vuelve a llegar, y con `remember` el diálogo se
// cerraría dejando al usuario dentro con la sesión temporal.
var cambiandoPassword by rememberSaveable { mutableStateOf(false) }
// `LaunchedEffect(recuperacion)` se ejecuta cada vez que llega un enlace
// distinto. El enlace trae una sesión temporal: se adopta, el usuario entra
// y el diálogo de contraseña nueva queda encima de la app.
LaunchedEffect(recuperacion) {
val enlace = recuperacion ?: return@LaunchedEffect
viewModel.abrirRecuperacion(enlace)
cambiandoPassword = true
alConsumirRecuperacion()
}
// 4. Debajo del `when`, antes de la llave que cierra la función:
// El diálogo solo se enseña con sesión: la que acaba de traer el enlace.
if (cambiandoPassword && estado.haySesion) {
NuevaPasswordDialog(alTerminar = { cambiandoPassword = false })
}return@LaunchedEffect (la etiqueta @LaunchedEffect dice de qué bloque se sale) sale solo del bloque del efecto (un return a secas intentaría salir de RaizConSesion), y alConsumirRecuperacion() avisa a la actividad de que el enlace ya se usó, para que no se repita al recomponer.
La actividad recibe el enlace. En MainActivity.kt el orden de las dos comprobaciones importa: el enlace de recuperación también encaja en el esquema de la app, así que hay que descartarlo antes de intentar leerlo como pantalla. Son seis cambios, numerados en el bloque en el orden en que aparecen en el fichero; la función común del 6 sustituye a las dos líneas que asignaban destinoEntrante (la de onCreate y la de onNewIntent):
// 1. Import nuevo, arriba:
import com.sebastiannarvaez.pokedex.feature.auth.EnlaceDeRecuperacion
// 2. Estado nuevo, debajo de `destinoEntrante`:
// El enlace del correo de recuperación. Va aparte del destino porque no es
// una pantalla: trae una sesión y abre un diálogo encima de la app.
private val recuperacionEntrante = mutableStateOf<EnlaceDeRecuperacion?>(null)
// 3. En onCreate, dentro del `if (savedInstanceState == null) { … }`.
// Antes:
// `dataString` es la URL; `parse` la traduce o devuelve null.
destinoEntrante.value = intent?.dataString?.let(Destination::parse)
// Después (el `if` se queda como está):
// `dataString` es la URL; `recibir` decide qué es.
recibir(intent?.dataString)
// 4. En onNewIntent.
// Antes:
destinoEntrante.value = intent.dataString?.let(Destination::parse)
// Después:
recibir(intent.dataString)
// 5. La llamada al guardián (`RaizConSesion`, dentro de `PokedexTheme`):
// antes era `RaizConSesion { PokedexApp(…) }`. Ahora recibe el enlace, y lo
// de dentro no cambia:
RaizConSesion(
recuperacion = recuperacionEntrante.value,
alConsumirRecuperacion = { recuperacionEntrante.value = null },
) {
PokedexApp(
destinoEntrante = destinoEntrante.value,
alConsumirDestino = { destinoEntrante.value = null },
)
}
// 6. La función nueva, al final de la clase:
// Un enlace entrante es una de dos cosas, y el orden importa: el de
// recuperación TAMBIÉN encaja en el esquema de la app (pokedex://), así que
// hay que descartarlo antes de intentar leerlo como pantalla.
private fun recibir(url: String?) {
val enlace = url ?: return
val recuperacion = EnlaceDeRecuperacion.parse(enlace)
if (recuperacion != null) {
recuperacionEntrante.value = recuperacion
} else {
destinoEntrante.value = Destination.parse(enlace)
}
}Ejecuta la app de Android: en el formulario de entrada ya aparece «¿Olvidaste tu contraseña?» (la lección anterior) y ahora, además, la app sabe qué hacer con el enlace que llegue.
Paso 4 · La contraseña nueva, en iOS
El núcleo, para Swift. AuthIos.kt (el que escribiste en El formulario de entrada en iOS) necesita lo mismo para AccountViewModel: una fábrica y su estado. Añade esto debajo de hayCuentas:
// Lo mismo para el ViewModel de la cuenta (por ahora, cambiar la contraseña).
fun accountViewModel(owner: ViewModelStoreOwner): AccountViewModel {
val koin = KoinPlatform.getKoin()
return ViewModelProvider.create(
owner = owner,
factory = viewModelFactory { initializer { koin.get<AccountViewModel>() } },
)[AccountViewModel::class]
}
@NativeCoroutinesState
val AccountViewModel.accountStateForIos: StateFlow<AccountState>
get() = stateLa hoja de contraseña nueva. NuevaPasswordView.swift es un Form dentro de una sheet (la hoja que sube desde abajo, como la de Un UIViewController en SwiftUI). Va en una sheet por la misma razón que el diálogo de Android: el usuario ya está dentro. Tiene un SecureField (el campo de contraseña de El formulario de entrada en iOS), un botón «Guardar» apagado hasta que la contraseña llega al mínimo, y «Ahora no» para cerrarla. Es maquetación, se copia. La carpeta Auth ya existe; en Xcode, clic derecho sobre ella → New File from Template… → Swift File, nombre NuevaPasswordView. Después Copiar fichero, selecciona todo (Cmd+A) y pega (Cmd+V) sobre lo que Xcode haya puesto:
Antes de pegarlo, su esquema, para saber qué mirar (los … son maquetación que ya conoces del formulario de la lección anterior):
struct NuevaPasswordView: View {
let alTerminar: () -> Void // lo que llama al cerrarse: la raíz apaga la hoja
@StateObject private var owner = IosViewModelStoreOwner() // el almacén de su ViewModel
@State private var viewModel: AccountViewModel?
@State private var estado = AccountState(…) // lo último que dijo el núcleo
@State private var password = ""
var body: some View {
NavigationStack {
Form { … SecureField + pie con el error o el mínimo … }
.toolbar { … «Ahora no» y «Guardar» (apagado si es corta o hay petición) … }
}
.onChange(of: estado.passwordCambiada) { … si cambió, alTerminar() … }
.task { … crea el ViewModel y escucha su estado … }
}
}Lo que tiene lógica y no solo maquetación: el .onChange(of: estado.passwordCambiada) cierra la hoja cuando el cambio sale bien, y el footer enseña el error del servidor si lo hay o, si no, el mínimo de caracteres.
El guardián recibe el enlace. En RaizConSesion.swift, tres cambios: el estado, los dos modificadores y la atención al enlace guardado.
// 1. Debajo de `@State private var estado`:
// Si la hoja de contraseña nueva está abierta.
@State private var cambiandoPassword = false
// El enlace que llegó antes de que exista el ViewModel (arranque en frío).
@State private var enlacePendiente: EnlaceDeRecuperacion?
// 2. Debajo de `.animation(.default, value: estado.haySesion)`:
// El enlace de recuperación TAMBIÉN encaja en el esquema de la app, así
// que hay que mirarlo antes de que nadie lo lea como pantalla.
.onOpenURL { url in
guard let enlace = EnlaceDeRecuperacion.companion.parse(url: url.absoluteString) else { return }
// Arranque en frío: el ViewModel aún no existe. Se guarda y la
// `.task` lo atiende. (En el simulador la `.task` suele llegar
// antes, pero SwiftUI no promete el orden.)
guard let viewModel else {
enlacePendiente = enlace
return
}
// Adopta la sesión temporal del enlace y abre la hoja.
viewModel.abrirRecuperacion(enlace: enlace)
cambiandoPassword = true
}
.sheet(isPresented: $cambiandoPassword) {
NuevaPasswordView { cambiandoPassword = false }
}
// 3. En la `.task` que ya tienes, justo debajo de `estado = vm.authStateForIos`:
// Si llegó un enlace antes de que existiera el ViewModel, se atiende ahora.
if let enlace = enlacePendiente {
enlacePendiente = nil
vm.abrirRecuperacion(enlace: enlace)
cambiandoPassword = true
}.onOpenURL recibe el enlace tanto con la app abierta como cuando el propio enlace la arranca desde cero, así que no hay que distinguir los dos casos (en Android son dos sitios, onCreate y onNewIntent). Hay un detalle del arranque en frío: el viewModel lo crea la .task de abajo, y el enlace puede llegar antes de que ella corra. En el simulador la .task suele llegar antes, pero SwiftUI no promete el orden: por eso el guard let viewModel guarda el enlace en enlacePendiente y la .task lo atiende en cuanto el ViewModel existe. Si el enlace llegara primero y esto no existiera, se perdería. guard let desenvuelve el opcional y sale si es nil: después de esa línea, el valor ya es de verdad (guard let y textos que no son números).
Dos manejadores a la vez. ContentView también tiene su .onOpenURL (el de Deep links en iOS), y con sesión SwiftUI avisa a los dos. No se pisan: Destination.parse no entiende auth/callback y devuelve nil, así que ContentView no hace nada con este enlace. Y sin sesión ContentView ni existe, así que solo lo recibe la raíz.
Vuelve a compilar el framework (./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 en la Terminal, porque el núcleo cambió en los pasos 1, 2 y este), compila (Cmd+B) y ejecuta en el simulador.
Paso 5 · Pruébalo con el enlace de verdad
No hace falta un correo para comprobarlo. Con la app de Android abierta y con Supabase configurado (la guardia haySupabase de Raiz.kt, que solo es verdadera si has puesto tu URL y tu clave en local.properties; en iOS es hayCuentas(), de El formulario de entrada en iOS), en la Terminal. Toda la orden va entre comillas dobles y la dirección entre simples: adb shell vuelve a interpretar lo que le pasas dentro del móvil, y sin las dobles el primer & cortaría la dirección y el enlace llegaría sin type=recovery:
adb shell "am start -a android.intent.action.VIEW -d 'pokedex://auth/callback#access_token=eyJhbGci.abc&expires_in=3600&refresh_token=v1kqNrTz&token_type=bearer&type=recovery'"adb es la herramienta que habla con el emulador (La Activity); am start -a VIEW -d … abre un enlace como si lo hubieras pulsado. La app adopta la sesión, entra, y el diálogo de contraseña nueva queda encima de la Pokédex. Si giras el emulador con el diálogo abierto, sigue ahí: es lo que consigue rememberSaveable. «Ahora no» lo cierra.
En el simulador de iOS, con la app abierta, el equivalente es el de abajo. La primera vez el simulador pregunta «¿Abrir en "Pokédex"?»: pulsa Abrir. Con la app cerrada también funciona: es el arranque en frío del que hablaba el paso 4.
xcrun simctl openurl booted 'pokedex://auth/callback#access_token=eyJhbGci.abc&expires_in=3600&refresh_token=v1kqNrTz&token_type=bearer&type=recovery'Los tokens de estos enlaces son inventados: el diálogo (o la hoja) sale, pero «Guardar» fallará porque el servidor no los conoce. Para cambiar la contraseña de verdad, pide el correo con «¿Olvidaste tu contraseña?» y pulsa el enlace que llega.
Provócalo tú. Prueba la misma orden (adb shell "am start …", o simctl openurl) con type=signup en lugar de type=recovery, y los tokens que quieras (access_token=abc&refresh_token=def). No pasa nada: EnlaceDeRecuperacion.parse devuelve null porque type no es recovery, Destination.parse tampoco entiende la dirección y el enlace se descarta. Es el test un enlace de confirmacion no es de recuperacion en acción. Sin la comprobación del type, confirmar un registro abriría la pantalla de contraseña nueva a quien solo estaba confirmando su correo.
Recoge la sesión falsa al terminar. Con el enlace de mentira la app ha guardado una sesión que el servidor no reconoce. Cierra el diálogo y, en Android, entra en Ajustes → Cuenta y pulsa «Cerrar sesión»; en iOS, en Ajustes, «Cerrar sesión» y confirmar. La app vuelve al formulario con la sesión limpia. En el simulador de iOS la sesión falsa sobrevive incluso a desinstalar la app, porque vive en el Llavero; si la quieres borrar de golpe: xcrun simctl keychain booted reset.
Para dejar los tests verificados, en la Terminal, en la carpeta de tu proyecto: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test, y abre los informes como en Ajustes en el núcleo (open shared/build/reports/tests/jvmTest/index.html, y lo mismo con testAndroidHostTest e iosSimulatorArm64Test). Esta lección añade los seis de EnlaceDeRecuperacionTest: de 117, 79 y 82 pasas a 123, 85 y 88.
Punto de control
- Los informes de tests dan 123 (JVM), 85 (Android host) y 88 (iOS): los seis de
EnlaceDeRecuperacionTest. - Un enlace con
type=signupno se toma por recuperación, y uno al que le falta el token de refresco se descarta entero. - La orden
adb shell "am start …"con el enlace de recuperación abre el diálogo de contraseña nueva encima de la app, y sigue ahí al girar el emulador (y elsimctl openurlabre la hoja en iOS). - «Ahora no» cierra el diálogo o la hoja.
- La compilación de Android y la de Xcode (Cmd+B) terminan sin errores.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué los tokens del enlace de recuperación viajan en el fragmento (
#) y no en la consulta (?)?2.¿Por qué
EnlaceDeRecuperacionno está enDestination?3.Llega
pokedex://auth/callback#access_token=abc&refresh_token=def&type=signup. ¿Qué hace la app?4.¿Por qué
cambiandoPasswordusarememberSaveabley noremember?5.El diálogo de contraseña nueva llama a
reiniciar()en sus tres salidas. ¿Qué pasaría sin eso?6.En
texto.split('&').mapNotNull { … }.toMap(), ¿qué hacemapNotNull?