Skip to content

feat(ios): capa social y de teléfono de iOS completa (pila 003→010) - #21

Merged
hgarciaalberto merged 9 commits into
developfrom
feature/010-ios-build-hygiene
Aug 19, 2026
Merged

feat(ios): capa social y de teléfono de iOS completa (pila 003→010)#21
hgarciaalberto merged 9 commits into
developfrom
feature/010-ios-build-hygiene

Conversation

@rndevelo

@rndevelo rndevelo commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Esta PR ya no es solo la 010. Se ha reapuntado a develop para cerrar de una vez la pila
entera 003 → 010, en lugar de hacer ocho squash encadenados con su rebase correspondiente
detrás. Sustituye a #14, #15, #16, #17, #18, #19, #20 y #21, que se cierran referenciando este
merge.

Qué entra

Spec Commit Qué hace
003 ce7489f Apple Sign-In de producción en el demo y contrato correcto para Swift
004 c35226a Cablea GitHub, Microsoft, Twitter, Facebook y teléfono en el demo
004 3dd9744 Spec 004, escrito a posteriori
005 1d5e107 Revoca el token de Apple antes de borrar la cuenta
006 cb7a322 Cierra también la sesión de GoogleSignIn al salir, en iOS
007 0512a46 Pasa los scopes de AppleSignInConfig al handler de iOS
008 6fc5fae Fija el bundle id de iOS y alinea el magic link
009 6e4e4cb Sesión de teléfono completa y testeable en las dos plataformas
010 facf5c2 Documenta lo que falta para construir el demo y deprecar código muerto

Por qué de golpe

Las PRs #15#21 apuntaban cada una a la rama de la anterior, y el CI solo disparaba en PRs
contra develop/main — así que ninguna de las ocho había pasado un solo check. Eso lo
arregla #23, ya mergeado. Al reapuntar esta PR a develop, el CI valida por fin la pila entera
de una sola pasada.

Merge en squash: develop se queda con un commit por PR, como marca la convención del repo.

Contexto

Detrás de esto viene el renombrado custom-loginbaselogin (módulo, paquete Kotlin,
namespace y recursos), que toca 142 ficheros y saldrá como 2.0.0. Se ha decidido meterlo
después de la pila precisamente para no obligar a rebasar nueve ramas a través de un rename
de directorio.

🤖 Generated with Claude Code

rndevelo and others added 9 commits August 14, 2026 02:52
… para Swift

Apple era el único provider con entitlement en el demo y sin una sola línea de
Swift: el botón solo registraba un warning y devolvía null. Se añade
AppleSignInCoordinator, que es la implementación de referencia que el README
manda copiar, y cierra los cuatro fallos que solo aparecen en runtime:

- nonce con SecRandomCopyBytes comprobando el OSStatus, abortando en vez de caer
  a un valor predecible; si falta el nonce en el delegate se rechaza el token en
  lugar de firmar sin protección de replay;
- referencia fuerte al ASAuthorizationController, que el sistema no retiene y sin
  la cual la hoja no llega a aparecer;
- un único finish(), de modo que la completion de Kotlin se llama exactamente una
  vez en todos los caminos, cancelación incluida — si no, la corrutina no reanuda
  y el botón se queda girando para siempre;
- el fullName, que Apple manda solo en la PRIMERA autorización de cada usuario,
  viaja en el segmento |||displayName||| que applyPendingDisplayName persiste.

El KDoc de AppleSignInProviderIOS es el contrato de integración y enseñaba Swift
que no compila: `.companion` sobre un `object` no existe, solo lo tiene el
companion de una clase (GoogleSignInProviderIOS). El mismo error estaba en los
otros cinco providers, así que se corrige en todos.

Logger.ios.kt pasaba el mensaje ya interpolado como format string de NSLog. Está
justo en la ruta de error de Apple, y un `%` en el mensaje —las URLs
percent-encoded de OAuth los llevan— lee memoria arbitraria de los varargs.

Sin cambios en commonMain: la API pública y los cinco tests de FLE-90 sobre el
formato de token de Apple quedan intactos. Verificado ktlintCheck y
testDebugUnitTest; el lado Swift no es compilable desde Windows y queda pendiente
de xcodebuild en Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
… el demo

MainViewController ya habilitaba los cinco providers, así que la pantalla pintaba
sus botones, pero en Swift no había ningún handler: cada pulsación registraba un
warning, devolvía null y el usuario veía "cancelled or failed". Con Apple ya
resuelto, quedaban estos.

FirebaseOAuthCoordinator cubre los cuatro que Firebase resuelve por su flujo OAuth
web, igual que WebOAuthProviderAndroid en la otra plataforma. Aquí es Swift quien
completa el login entero y la librería solo necesita saber que ocurrió: eso es el
centinela PLATFORM_AUTH_HANDLED, que se lee de cada objeto en vez de repetir la
cadena mágica. Los scopes replican los de LoginLibraryConfig para que ambas
plataformas pidan lo mismo. El OAuthProvider se guarda en un diccionario por
provider id porque el SDK no lo retiene mientras dura el flujo: en una variable
local se libera antes del callback y el navegador abre para no volver nunca.

PhoneAuthCoordinator cubre los dos saltos del OTP.

La parte que faltaba en ambos y que no está en el código de login: el callback de
vuelta. Con el ciclo de vida de SwiftUI, application(_:open:options:) no se llama
—las URLs llegan a la escena—, así que el flujo web y el fallback reCAPTCHA del
teléfono no volvían nunca. Se añade .onOpenURL sobre el WindowGroup y un
AppDelegate.handle(_:) que da prioridad a Auth.auth().canHandle(url) antes que a
GoogleSignIn. Se implementan también setAPNSToken y canHandleNotification, que son
lo que permite a Firebase verificar la app con un push silencioso en vez de
mandar al usuario a una página de reCAPTCHA.

Solo cambia el demo y el README: la librería no se toca.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
…de iOS

Se implementó como fix directo, fuera del harness. El spec se escribe después para
que el trabajo quede documentado en specs/ como el resto: alcance, quién firma en
cada provider, los tres detalles que deciden si el flujo funciona y una tabla de
trazabilidad honesta —ningún AC es unitario, todo el código nuevo es Swift y el
repo no tiene target de test de iOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
La guía 5.1.1(v) de App Review no se conforma con eliminar el usuario de Firebase:
para Sign in with Apple exige revocar el token, o la app sigue apareciendo en
Ajustes → Cuenta de Apple y la revisión se rechaza. Ningún consumidor podía pasar
revisión sin escribirse esto por su cuenta, que es justo lo que la librería existe
para evitar.

Entra como tercer puerto de la familia de FLE-90 —SocialTokenRevoker, junto a
SocialTokenProvider y SocialSignInStateCleaner— por el mismo motivo que los otros
dos: debajo hay una función expect de nivel superior, y esas no se pueden falsear
desde commonTest. Con default en el constructor, así que quien construya
FirebaseAuthProvider con tres argumentos sigue compilando; los tests de FLE-90 no
se tocan y son la prueba.

A quién revocar lo decide el usuario, no la configuración: FirebaseAuthUser gana
providerIds y el adaptador de GitLive lo rellena desde providerData. Un usuario de
email en una app con Apple habilitado no debe ver ninguna hoja de Apple al borrar
su cuenta.

Best effort a propósito: si la revocación falla, la cuenta se borra igual. Quien
pide borrar su cuenta no puede quedarse atrapado porque un servidor no conteste, y
el resultado contrario —cuenta viva, token revocado— es peor para él.

Opt-in en iOS: sin revokeHandler asignado se registra un warning y no pasa nada,
para que un host que ya tiene en producción su flujo de borrado no se encuentre de
golpe una hoja de Apple al actualizar el pin.

El handler del demo vuelve a lanzar la autorización de Apple en vez de reutilizar
el código del login: revokeToken exige un authorizationCode fresco y de un solo
uso, y de paso satisface el recent login que Firebase pide para borrar.

testOptions.unitTests.isReturnDefaultValues: android.util.Log es un stub que lanza
"not mocked" en tests de host, así que cualquier test que recorra un catch fallaba
por el log y no por el comportamiento. Salió al escribir AC-03.

Rojo antes de implementar en AC-01, AC-03 y AC-05; AC-02 y AC-04 nacieron verdes y
se documentan como guardias de regresión en la tabla de trazabilidad, en vez de
apuntarlos como TDD que no fue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
clearSocialSignInState existía desde FLE-90 porque cerrar sesión en Firebase no
basta, y en iOS se dejó como no-op con un comentario que decía que ni
ASAuthorizationController ni GIDSignIn cachean nada. De Apple es cierto; de Google
no: GIDSignIn.sharedInstance.currentUser vive en el llavero y sobrevive al signOut
de Firebase, así que en iOS el siguiente login reutilizaba la cuenta anterior y el
usuario no tenía forma de cambiar de cuenta desde la app. Es la misma avería que el
ticket original arregló en Android, en la plataforma que se dio por buena sin
comprobarla.

Se resuelve con un seam, no con una llamada directa, porque :custom-login no
depende del SDK de GoogleSignIn ni debe hacerlo: el host lo trae por SPM. Sin
handler asignado el comportamiento es el de hoy —un warning y nada más—, y un
handler que lance se registra y se traga, porque la sesión de Firebase ya está
cerrada a esas alturas y fallar aquí solo convertiría un cierre correcto en un
error visible.

No añade tests, y el spec lo dice explícitamente: todo lo que cambia vive por
debajo de SocialSignInStateCleaner, que es justo donde commonTest deja de alcanzar.
Comprobar otra vez que signOut llama al puerto sería duplicar el test de FLE-90 y
fingir cobertura nueva.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
AppleSignInConfig.scopes es configuración pública y en Android se lee de verdad
para el flujo OAuth web. En iOS no la leía nadie: el handler recibía un String?
documentado como "reservado para futura config" y siempre valía null, así que los
scopes estaban a fuego en el host. Un campo que funciona en una plataforma y en la
otra no es peor que no tenerlo, porque quien lo cambie creerá que aplica a las dos.

Se llena el hueco que el seam ya tenía reservado, así que es aditivo: un host
integrado hoy lo ignora con "_" y sigue pidiendo lo mismo, que además es el valor
por defecto configurado. Viaja como cadena separada por comas y no como lista
porque una List<String> cruza a Swift como [Any] y obligaría al host a castear.

El fallback es "los dos scopes", nunca "ninguno": pedir cero scopes hace que Apple
no devuelva el nombre, y el nombre solo llega en la primerísima autorización de
cada usuario. Un error de configuración no puede costar eso.

signIn() gana el parámetro con default, así que ninguna llamada existente se
rompe. Sin tests nuevos, y el spec explica por qué: el camino entero vive en el
actual de iOS y en Swift, al otro lado de la frontera de commonTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
El bundle id venía de la plantilla de KMP como com.apptolast.login.Login$(TEAM_ID).
Para un ejemplo de usar y tirar está bien; aquí no, porque esa cadena tiene que
coincidir a la vez con la app de iOS registrada en Firebase, con el cliente OAuth
que emitió el reversed client id del Info.plist y con MagicLinkConfig.iosBundleId.
Con el sufijo, rellenar TEAM_ID en local cambiaba la identidad de la app y rompía
las tres en silencio.

Y aun sin rellenarlo ya había discrepancia: el bundle era com.apptolast.login.Login
y el magic link decía com.apptolast.login, así que el enlace no reabría la app.

Se conserva com.apptolast.login.Login, no el otro candidato, porque es el que está
en el árbol hoy con TEAM_ID vacío y por tanto el único que sabemos que casa con lo
registrado en la consola. El GoogleService-Info.plist está en .gitignore y no se
puede comprobar desde aquí; cambiar la identidad de la app por una corazonada
rompería Google Sign-In sin avisar. Si al validar en Mac resulta que Firebase tiene
com.apptolast.login, el arreglo es una línea en cada uno de los dos sitios y está
escrito en el spec.

Sigue faltando, y queda fuera de este ticket, montar los Universal Links de
apptolast.com: sin eso el magic link no vuelve a la app aunque el bundle id ya sea
correcto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
…ormas

Entrar por SMS dejaba una sesión distinta según la plataforma: Android la
construye leyendo el FirebaseUser, iOS solo recibe el uid de Swift y fabricaba
UserSession(userId, email = null). Cualquier pantalla que pinte el email o mire
isEmailVerified se comportaba distinto para el mismo usuario, y la que se
equivocaba era iOS.

El arreglo va en el sitio común, no en cada actual: cuando la plataforma dice que
ha firmado, el usuario ya está en Firebase, así que la fuente de verdad es el
gateway. Es el mismo patrón que ya usa SocialTokenResult.PlatformHandled, donde
Swift firma y Kotlin vuelve a leer la sesión. Si el gateway no ve usuario se
conserva el resultado de la plataforma, que es mejor que inventar un fallo cuando
la plataforma acaba de decir que fue bien.

Debajo había un agujero mayor: los dos puntos de entrada del teléfono son
funciones expect de nivel superior, así que el flujo entero era intesteable desde
commonTest — el mismo hueco que FLE-90 cerró para el resto del provider y dejó
abierto aquí. Entra PhoneAuthPort, cuarto puerto de la misma familia, con default
en el constructor.

Rojo antes de implementar solo AC-01, que es el único que cambia comportamiento;
los otros tres tests son cobertura que antes no se podía escribir y así se apuntan
en la trazabilidad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
… código muerto

Cuatro restos de la auditoría, ninguno urgente y todos de los que cuestan una tarde
a quien llega nuevo.

El demo no se puede construir desde un clon limpio: google-services.json y
GoogleService-Info.plist están en .gitignore y no están en el árbol. El primero
rompe assembleDebug con un error claro; el segundo deja compilar y la app casca al
arrancar en FirebaseApp.configure(). Ahora CLAUDE.md dice qué dos ficheros hacen
falta, dónde van y qué pasa si falta cada uno. Salió al ejecutar assembleDebug en
el spec 008: hasta entonces solo se habían corrido tareas de :custom-login, que no
necesitan ninguno.

onSignInResult no lo llama nadie —el resultado viaja en el completion del
signInHandler— y getTopViewController usa UIApplication.windows, deprecado en iOS
15 e indiferente a qué escena está en primer plano. Se deprecan con WARNING en vez
de borrarse: es API pública de una librería que los consumidores pinean por SHA, y
borrar obliga a cambiar a todo el mundo.

El handler de Google del demo cogía connectedScenes.first, que puede ser una escena
en segundo plano, y registraba con print. Los tres coordinadores de los specs
003-005 hacen ambas cosas bien, así que desentonaba justo en el fichero que un
consumidor abre primero.

El pbxproj NO se toca, y el spec explica por qué: desde FLE-91 el enlazado de
ComposeApp.framework es implícito, y la ruta correcta depende de dónde deje el
framework embedAndSignAppleFrameworkForXcode. Eso no se puede comprobar en Windows,
y escribir una ruta plausible en un fichero de build que no puedo compilar es peor
que dejarlo, porque parece un arreglo. Quedan los dos comandos que lo resuelven en
el Mac.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rkh7MzVRj5iXT2rLiqyW6A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants