diff --git a/CLAUDE.md b/CLAUDE.md index 9dccab8..ef84709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Run all tests ./gradlew :custom-login:testDebugUnitTest -# iOS - build Kotlin framework -./gradlew :custom-login:linkDebugFrameworkIosArm64 -./gradlew :custom-login:linkDebugFrameworkIosSimulatorArm64 +# iOS - build Kotlin framework. +# The task lives in :composeApp, not :custom-login — the library declares no +# binaries.framework of its own, it is export()ed through the demo's ComposeApp framework. +./gradlew :composeApp:linkDebugFrameworkIosArm64 +./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 # iOS demo app - open iosApp/iosApp.xcodeproj (NOT a .xcworkspace: there is no CocoaPods) xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -configuration Debug \ @@ -112,7 +114,13 @@ Default implementations live in `presentation/slots/defaultslots/`. ### Library Entry Points - Kotlin: `initLoginKoin(config: LoginLibraryConfig, appDeclaration?)` — call once at app start - Compose: `AuthNavFlow(authSlots, onAuthSuccess)` from `RootNavGraph.kt` -- iOS helper: `GoogleSignInProviderIOS.signInHandler` must be set from Swift +- iOS helper: `GoogleSignInProviderIOS.shared.signInHandler` must be set from Swift + +All six iOS providers are `object`s, so Swift reaches every one of them the same way — +`X.shared.…`. There is deliberately no second form: `GoogleSignInProviderIOS` used to be a +`class` whose companion Kotlin/Native exported separately, and the two coexisting spellings are +what let the README publish sign-in examples that did not compile. If a provider ever needs +per-call configuration, it travels as a `signIn(...)` parameter — never in a constructor. ### Dependency Injection `LoginLibraryConfig` is registered as a Koin `single`. If `googleSignInConfig != null`, `GoogleSignInConfig` is also registered. `AuthRepositoryImpl` takes `AuthProvider` and `LoginLibraryConfig`. @@ -229,10 +237,10 @@ pattern. ./gradlew :custom-login:testDebugUnitTest # iOS compile + link -./gradlew :custom-login:linkDebugFrameworkIosSimulatorArm64 +./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 # Full check before opening a PR -./gradlew :custom-login:testDebugUnitTest :custom-login:linkDebugFrameworkIosSimulatorArm64 \ +./gradlew :custom-login:testDebugUnitTest :composeApp:linkDebugFrameworkIosSimulatorArm64 \ :composeApp:assembleDebug --console=plain ``` diff --git a/README.md b/README.md index fa3ede4..661b28c 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,10 @@ All providers are **opt-in** via `LoginLibraryConfig`. Disabled providers are no 2. [Architecture Overview](#architecture-overview) 3. [Prerequisites](#prerequisites) 4. [Project Setup](#project-setup) -5. [Initialization](#initialization) -6. [Integrating the Navigation Flow](#integrating-the-navigation-flow) -7. [Provider Configuration](#provider-configuration) +5. [Migrating to 2.0.0](#migrating-to-200) +6. [Initialization](#initialization) +7. [Integrating the Navigation Flow](#integrating-the-navigation-flow) +8. [Provider Configuration](#provider-configuration) - [Google Sign-In](#google-sign-in) - [Apple Sign-In](#apple-sign-in) - [GitHub](#github) @@ -123,17 +124,17 @@ All providers are **opt-in** via `LoginLibraryConfig`. Disabled providers are no - [Facebook](#facebook) - [Phone OTP](#phone-otp) - [Magic Link](#magic-link) -8. [iOS Platform Setup](#ios-platform-setup) +9. [iOS Platform Setup](#ios-platform-setup) - [Google (iOS)](#google-ios) - [Apple (iOS)](#apple-ios) - [GitHub / Microsoft / Twitter / Facebook (iOS)](#github--microsoft--twitter--facebook-ios) - [Phone OTP (iOS)](#phone-otp-ios) -9. [Customizing the UI — Slots System](#customizing-the-ui--slots-system) -10. [Re-authentication Screen](#re-authentication-screen) -11. [AuthRepository Public API](#authrepository-public-api) -12. [Error Handling](#error-handling) -13. [Localization](#localization) -14. [Module Structure](#module-structure) +10. [Customizing the UI — Slots System](#customizing-the-ui--slots-system) +11. [Re-authentication Screen](#re-authentication-screen) +12. [AuthRepository Public API](#authrepository-public-api) +13. [Error Handling](#error-handling) +14. [Localization](#localization) +15. [Module Structure](#module-structure) --- @@ -301,6 +302,27 @@ The library's own dependencies (Firebase, Koin, Compose, etc.) are defined in `c --- +## Migrating to 2.0.0 + +2.0.0 is a **breaking release**. Everything in it fails at compile time, never at runtime — you find +out when you bump the pin, not a month later with a dead button. + +### iOS providers: one way to reach them from Swift + +`GoogleSignInProviderIOS` was the only provider shaped as a `class` with a `companion object`, +because it took `GoogleSignInConfig` in its constructor. Kotlin/Native exported that companion +separately, so the same seam had two spellings in Swift — and this README shipped sign-in examples +using the wrong one. It is now an `object` like the other five, and the config travels in `signIn`. + +| | 1.x | 2.0.0 | +|---|---|---| +| Swift | `GoogleSignInProviderIOS.Companion.shared.signInHandler = …` | `GoogleSignInProviderIOS.shared.signInHandler = …` | +| Swift | `GoogleSignInProviderIOS.Companion.shared.signOutHandler = …` | `GoogleSignInProviderIOS.shared.signOutHandler = …` | +| Kotlin | `GoogleSignInProviderIOS(config).signIn()` | `GoogleSignInProviderIOS.signIn(config)` | + +`getClientId()` is gone: anyone who could call it already holds the `GoogleSignInConfig` it read +from. All six providers now answer to `X.shared.…`, and nothing else. + ## Initialization Call `initLoginKoin` before any auth Composable is shown. If Koin is already running, `initLoginKoin` loads the login modules into the existing container instead of calling `startKoin` again. Apps that need full control can use `loginModules` directly. @@ -606,7 +628,7 @@ Set up all handlers **before** the first Composable renders, typically in `AppDe import GoogleSignIn // In AppDelegate.application(_:didFinishLaunchingWithOptions:) or equivalent: -GoogleSignInProviderIOS.companion.signInHandler = { clientId, completion in +GoogleSignInProviderIOS.shared.signInHandler = { clientId, completion in guard let clientId = clientId, let rootVC = UIApplication.shared.connectedScenes .compactMap({ ($0 as? UIWindowScene)?.keyWindow?.rootViewController }) @@ -630,7 +652,7 @@ GoogleSignInProviderIOS.companion.signInHandler = { clientId, completion in } // Wire this too, or the user can never switch accounts -GoogleSignInProviderIOS.Companion.shared.signOutHandler = { +GoogleSignInProviderIOS.shared.signOutHandler = { GIDSignIn.sharedInstance.signOut() } ``` diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt index cdf6489..3fcec26 100644 --- a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt @@ -65,8 +65,7 @@ actual suspend fun getSocialIdToken(provider: IdentityProvider): SocialTokenResu return null } - val googleProvider = GoogleSignInProviderIOS(config = config) - googleProvider.signIn()?.let { SocialTokenResult.Token(it) } + GoogleSignInProviderIOS.signIn(config)?.let { SocialTokenResult.Token(it) } } is IdentityProvider.Apple -> { // Same source of truth as Android, which reads these scopes for its web OAuth flow. @@ -120,7 +119,7 @@ actual suspend fun clearSocialSignInState() { if (handler == null) { Logger.w( "Platform", - "signOutHandler not configured. Set GoogleSignInProviderIOS.Companion.shared.signOutHandler " + + "signOutHandler not configured. Set GoogleSignInProviderIOS.shared.signOutHandler " + "from Swift, or the Google account stays signed in after sign-out.", ) return diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/AppleSignInProviderIOS.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/AppleSignInProviderIOS.kt index 927425a..9f9b8d6 100644 --- a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/AppleSignInProviderIOS.kt +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/AppleSignInProviderIOS.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.suspendCancellableCoroutine * and the user sees a generic "cancelled or failed" error. * * This is a Kotlin `object`, so from Swift it is reached through **`.shared`** — - * `AppleSignInProviderIOS.shared.signInHandler = …`. (`.companion` exists only for the companion - * object of a class, such as `GoogleSignInProviderIOS`.) + * `AppleSignInProviderIOS.shared.signInHandler = …`. Every iOS provider in this library has that + * same shape, so `.shared` is the only form you ever need. * * ## Token format * diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt index 48aea00..69ece98 100644 --- a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt @@ -12,66 +12,71 @@ import platform.UIKit.UIWindow /** * iOS implementation of Google Sign-In. * - * This provider uses a callback mechanism to integrate with Swift. - * The hosting app should: + * This provider uses a callback mechanism to integrate with Swift. The hosting app should: * 1. Configure GoogleSignIn in Swift AppDelegate - * 2. Call [signInFromSwift] to trigger the sign-in flow - * 3. The result will be passed back via the callback + * 2. Set [signInHandler] to trigger the sign-in flow + * 3. The result travels back through the completion block handed to that handler * - * @property config The Google Sign-In configuration containing client IDs. + * An `object`, like the other five iOS providers, so there is a single way to reach any of them from + * Swift: `GoogleSignInProviderIOS.shared.…`. It used to be a `class` taking [GoogleSignInConfig] in + * its constructor, which Kotlin/Native exported through the companion instead and made this the odd + * one out — the reason the published examples drifted into two contradictory forms. The config now + * travels in [signIn], mirroring `AppleSignInProviderIOS.signIn(scopes)`. + * + * Making it an `object` does not globalise anything that was not global already: [signInHandler], + * [signOutHandler] and the pending callback lived in the companion, so there was ever only one of + * each per process. The old shape merely suggested otherwise. */ -class GoogleSignInProviderIOS(private val config: GoogleSignInConfig) { - companion object { - /** - * Callback to be set from Swift to perform the actual sign-in. - * Swift should set this and call GIDSignIn.sharedInstance.signIn(). - */ - var signInHandler: ((String?, (String?) -> Unit) -> Unit)? = null +object GoogleSignInProviderIOS { - /** - * Called from Swift to complete the sign-in with the ID token. - */ - private var pendingCallback: ((String?) -> Unit)? = null + /** + * Callback to be set from Swift to perform the actual sign-in. + * Swift should set this and call GIDSignIn.sharedInstance.signIn(). + */ + var signInHandler: ((String?, (String?) -> Unit) -> Unit)? = null - /** - * Called from Swift to provide the sign-in result. - */ - @Deprecated( - "Unused: the result travels in the completion block handed to signInHandler, which is " + - "what every integration does. Will be removed once no consumer references it.", - level = DeprecationLevel.WARNING, - ) - fun onSignInResult(idToken: String?) { - pendingCallback?.invoke(idToken) - pendingCallback = null - } + /** + * Set from Swift to clear GoogleSignIn's own session when the user signs out: + * + * ```swift + * GoogleSignInProviderIOS.shared.signOutHandler = { + * GIDSignIn.sharedInstance.signOut() + * } + * ``` + * + * Firebase's `signOut()` does not touch it. `GIDSignIn.sharedInstance.currentUser` lives in + * the keychain and survives, so without this the next Google sign-in silently reuses the + * previous account and **the user cannot switch accounts from inside the app** — the same + * failure `clearSocialSignInState` fixes on Android for Credential Manager. + * + * Leaving it unset keeps today's behaviour: a warning, and nothing else. + */ + var signOutHandler: (() -> Unit)? = null - /** - * Set from Swift to clear GoogleSignIn's own session when the user signs out: - * - * ```swift - * GoogleSignInProviderIOS.Companion.shared.signOutHandler = { - * GIDSignIn.sharedInstance.signOut() - * } - * ``` - * - * Firebase's `signOut()` does not touch it. `GIDSignIn.sharedInstance.currentUser` lives in - * the keychain and survives, so without this the next Google sign-in silently reuses the - * previous account and **the user cannot switch accounts from inside the app** — the same - * failure `clearSocialSignInState` fixes on Android for Credential Manager. - * - * Leaving it unset keeps today's behaviour: a warning, and nothing else. - */ - var signOutHandler: (() -> Unit)? = null + private var pendingCallback: ((String?) -> Unit)? = null + + /** + * Called from Swift to provide the sign-in result. + */ + @Deprecated( + "Unused: the result travels in the completion block handed to signInHandler, which is " + + "what every integration does. Will be removed once no consumer references it.", + level = DeprecationLevel.WARNING, + ) + fun onSignInResult(idToken: String?) { + pendingCallback?.invoke(idToken) + pendingCallback = null } /** * Initiates the Google Sign-In flow and returns the ID token. * + * @param config the client ids to hand to Swift; [GoogleSignInConfig.iosClientId] wins over + * [GoogleSignInConfig.webClientId] when both are set. * @return The Google ID token on success, or null if cancelled/failed. */ @OptIn(ExperimentalForeignApi::class) - suspend fun signIn(): String? = suspendCancellableCoroutine { continuation -> + suspend fun signIn(config: GoogleSignInConfig): String? = suspendCancellableCoroutine { continuation -> val handler = signInHandler if (handler == null) { Logger.w("GoogleSignIn", "Handler not configured. Set GoogleSignInProviderIOS.signInHandler from Swift.") @@ -122,9 +127,4 @@ class GoogleSignInProviderIOS(private val config: GoogleSignInConfig) { } return topController } - - /** - * Returns the iOS client ID for configuration. - */ - fun getClientId(): String? = config.iosClientId ?: config.webClientId } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 795475c..c45d355 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -72,12 +72,12 @@ class AppDelegate: NSObject, UIApplicationDelegate { private func configureGoogleSignIn() { // Firebase's signOut() does not touch GIDSignIn: its currentUser lives in the keychain, and // without this the next sign-in reuses the same account and nobody can switch. - GoogleSignInProviderIOS.Companion.shared.signOutHandler = { + GoogleSignInProviderIOS.shared.signOutHandler = { GIDSignIn.sharedInstance.signOut() } // Set up the Google Sign-In handler that Kotlin will call - GoogleSignInProviderIOS.Companion.shared.signInHandler = { clientId, completion in + GoogleSignInProviderIOS.shared.signInHandler = { clientId, completion in guard let clientId = clientId else { completion(nil) return diff --git a/specs/011-ios-provider-symmetry/spec.md b/specs/011-ios-provider-symmetry/spec.md new file mode 100644 index 0000000..684dcf1 --- /dev/null +++ b/specs/011-ios-provider-symmetry/spec.md @@ -0,0 +1,208 @@ +# Spec 011: Simetría de los providers de iOS — `GoogleSignInProviderIOS` como `object` + +> Rama: `feature/011-ios-provider-symmetry` · Proyecto: `BaseLogin` (`:custom-login` + demo + README) +> Estado: **implementado**. Sale de `feature/010-ios-build-hygiene`. +> Sin ticket FLE: cierra la causa de un hallazgo del spec 003. +> ⚠️ **Rompe a los hosts Swift ya integrados. Va en 2.0.0**, junto con el renombrado +> `custom-login` → `baselogin`, para que los consumidores migren una sola vez en vez de dos. + +## Contexto y objetivo + +El spec 003 corrigió `.companion` → `.shared` en el KDoc de los seis providers de iOS y en las siete +apariciones del README, porque los ejemplos que la librería publicaba **no compilaban**. Eso arregló +los síntomas y dejó la causa intacta. + +La causa es que hay dos formas de provider donde debería haber una: + +| Provider | Forma en Kotlin | Acceso desde Swift | +|---|---|---| +| `AppleSignInProviderIOS` | `object` | `.shared` | +| `GitHubSignInProviderIOS` | `object` | `.shared` | +| `MicrosoftSignInProviderIOS` | `object` | `.shared` | +| `TwitterSignInProviderIOS` | `object` | `.shared` | +| `FacebookSignInProviderIOS` | `object` | `.shared` | +| **`GoogleSignInProviderIOS`** | **`class` + `companion object`** | **`.companion` / `.Companion.shared`** | + +Google es el único distinto, y por una razón que no tiene que ver con Swift: recibe +`GoogleSignInConfig` en el constructor. Como fue también el primero que existió, alguien copió su +bloque de ejemplo a los otros cinco y ahí nació la documentación falsa: en Google `.companion` era +correcto, en los demás nunca lo fue. + +**La trampa sigue viva después del 003.** En esta rama base, el propio README usa las dos formas para +el mismo objeto con 24 líneas de diferencia: + +```swift +README.md:609 GoogleSignInProviderIOS.companion.signInHandler = { clientId, completion in +README.md:633 GoogleSignInProviderIOS.Companion.shared.signOutHandler = { +``` + +Y el demo (`iosApp/iosApp/iOSApp.swift:75,80`) usa la segunda para las dos. Las dos compilan +—Kotlin/Native exporta un `companion object` de una clase por ambos caminos— y por eso nadie lo ha +visto: no es un fallo, es ruido que enseña al lector que hay dos contratos. El siguiente que copie el +bloque equivocado a un `object` repite exactamente el 003. + +**Objetivo:** una sola manera de alcanzar cualquier provider de iOS desde Swift, `X.shared.…`, y que +el README no pueda volver a contradecirse. + +## Alcance + +**Dentro:** + +- `GoogleSignInProviderIOS`: `class` + `companion object` → `object`. La `config` sale del + constructor y entra en `signIn(config: GoogleSignInConfig)`, que es la forma que ya tiene + `AppleSignInProviderIOS.signIn(scopes)`. +- `Platform.ios.kt:68`, único call site en Kotlin de todo el repo. +- README líneas 609 y 633, y el resto de la sección de Google, a la forma única. +- `iosApp/iosApp/iOSApp.swift:75,80`. +- `CLAUDE.md:94`, que hoy nombra el seam sin decir cómo se alcanza desde Swift. +- Decidir el destino de `getClientId()` y `getTopViewController()` — ver *Decisión abierta*. +- Declarar el break: la versión la fija la PR del renombrado (**2.0.0**); aquí va la nota explícita + en el README, en una sección `Migrating to 2.0.0`. + +**Fuera:** + +- Los otros cinco providers. Ya son `object`; este spec los toma como referencia, no los toca. +- El formato del token social (`idToken|||accessToken|||…`). No cambia ni un separador. +- La lógica de sign-in. Es un cambio de **forma**, no de comportamiento: ni `PLATFORM_AUTH_HANDLED`, + ni el `signOutHandler` del spec 006, ni la ruta de handler ausente cambian de semántica. +- `commonMain` y sus tests. Nada de esto cruza a código común. + +## Diseño + +### El cambio + +```kotlin +// Antes +class GoogleSignInProviderIOS(private val config: GoogleSignInConfig) { + companion object { + var signInHandler: ((String?, (String?) -> Unit) -> Unit)? = null + var signOutHandler: (() -> Unit)? = null + private var pendingCallback: ((String?) -> Unit)? = null + fun onSignInResult(idToken: String?) { … } + } + suspend fun signIn(): String? { … config.iosClientId ?: config.webClientId … } +} + +// Después +object GoogleSignInProviderIOS { + var signInHandler: ((String?, (String?) -> Unit) -> Unit)? = null + var signOutHandler: (() -> Unit)? = null + private var pendingCallback: ((String?) -> Unit)? = null + fun onSignInResult(idToken: String?) { … } + suspend fun signIn(config: GoogleSignInConfig): String? { … } +} +``` + +### Por qué esto no empeora el estado compartido + +La objeción esperable a un `object` es que globaliza estado. Aquí no globaliza nada: `signInHandler`, +`signOutHandler` y `pendingCallback` **ya viven en el `companion object`**, es decir, ya son uno por +proceso. Lo que hay hoy es peor que global: es global disfrazado de instancia. `GoogleSignInProviderIOS(config)` +sugiere que dos instancias son independientes, y no lo son — comparten handler y `pendingCallback`, y +dos sign-in concurrentes se pisarían igual que ahora. El cambio no introduce el acoplamiento, lo hace +legible. + +### Superficie del break + +| Consumidor | Antes | Después | +|---|---|---| +| Swift (host) | `GoogleSignInProviderIOS.Companion.shared.signInHandler = …` | `GoogleSignInProviderIOS.shared.signInHandler = …` | +| Swift (host) | `GoogleSignInProviderIOS.Companion.shared.signOutHandler = …` | `GoogleSignInProviderIOS.shared.signOutHandler = …` | +| Kotlin (iosMain) | `GoogleSignInProviderIOS(config).signIn()` | `GoogleSignInProviderIOS.signIn(config)` | + +Lo que hace este break aceptable es que **falla en compilación, no en runtime**. El host se entera al +bumpear el pin, no un mes después con un botón muerto — que es exactamente el modo de fallo que +arrastraban los specs 003 y 004. + +Y como los consumidores pinean **SHA, no tag** (ver `CLAUDE.md`, *How this library is consumed*), no +es una publicación a ciegas: el break se coordina con el bump de Fledge en la misma tanda, un commit +aquí y dos líneas de Swift allí. La versión sube a 2.0.0 por honestidad semántica. Y sí hay quien +consume por versión: Paparcar pinea el tag `1.1.0`, no un SHA. + +### Decisión (era abierta): `getClientId()` y `getTopViewController()` + +Ambas son públicas y tienen **cero call sites** en este repo. `getClientId()` depende de la `config`, +así que al vaciar el constructor hay que hacer algo con ella; `getTopViewController()` no depende de +nada y sobrevive tal cual. + +Esta es la única ventana barata para tocarlas: ya estamos rompiendo el tipo. **Resuelto así:** +`getClientId()` **desaparece** (quien la llamaría ya tiene la `config` en la mano) y +`getTopViewController()` **se queda** como función del `object`, con su `@Deprecated` del spec 010 +intacto. + +El riesgo del «consumidor Swift desconocido» se pudo medir en lugar de estimar: `getClientId()` +tiene **cero call sites** en toda la organización `apptolast`, y ni Fledge ni Paparcar mencionan +`GoogleSignInProviderIOS` en Kotlin ni en Swift. Quitarla no rompe a nadie hoy. + +## Criterios de aceptación (Gherkin) + +```gherkin +Scenario [AC-01]: Un solo contrato desde Swift + Given los seis providers de iOS de la librería + When se accede a cualquiera de ellos desde Swift + Then la forma es siempre X.shared.… + And ningún ejemplo del repo (KDoc, README, demo) usa .companion ni .Companion.shared + +Scenario [AC-02]: La config viaja en signIn, no en el constructor + Given getSocialIdToken(IdentityProvider.Google) en iOS + When resuelve GoogleSignInConfig desde Koin + Then la pasa a GoogleSignInProviderIOS.signIn(config) + And no se construye ninguna instancia del provider + +Scenario [AC-03]: El comportamiento del login no se mueve + Given un login de Google completado en iOS + When Swift devuelve los tokens + Then Kotlin recibe idToken|||accessToken|||, sin cambios + And con signInHandler sin asignar sigue devolviendo null tras un warning + +Scenario [AC-04]: El signOut del spec 006 sigue en pie + Given una sesión de Google abierta en iOS + When clearSocialSignInState() se ejecuta + Then invoca signOutHandler sobre el object + And el siguiente login de Google vuelve a ofrecer selector de cuenta + +Scenario [AC-05]: README y demo dicen lo mismo + Given los snippets de Google del README y los de iosApp/iosApp/iOSApp.swift + When se comparan + Then usan la misma forma de acceso, literalmente + +Scenario [AC-06]: El break está declarado, no descubierto + Given un host Swift escrito contra 1.1.0 + When compila contra esta versión + Then falla en compilación con símbolo no resuelto + And el README declara el cambio como breaking de 2.0.0 +``` + +## Trazabilidad + +| AC | Test(s) | ¿Rojo antes? | +|----|---------|--------------| +| AC-01 | *(no unitario)* — `grep -rn "\.companion\.\|\.Companion\.shared" custom-login/src/iosMain iosApp README.md CLAUDE.md` debe salir vacío | n/a — el grep sale con 4 hits hoy | +| AC-02 | *(no unitario)* — `:custom-login:linkDebugFrameworkIosSimulatorArm64` + inspección de `Platform.ios.kt` | n/a en iosMain | +| AC-03 | `:custom-login:testDebugUnitTest` en verde sin tocar tests + smoke manual en Mac | n/a — el parseo vive en commonMain y no cambia | +| AC-04 | *(no unitario)* — smoke manual: login → logout → login y comprobar que aparece el selector | n/a | +| AC-05 | *(no unitario)* — diff manual de los dos snippets | n/a | +| AC-06 | *(no unitario)* — compilar el demo, que **es** el host de referencia | n/a | + +> Como en los specs 003 y 004: ningún AC de este spec es verificable con un test unitario nuevo. Todo +> lo que cambia vive en `iosMain` o en Swift, donde `commonTest` no llega, y el comportamiento +> deliberadamente no se mueve. Se dice explícitamente en vez de inventar tests de relleno. + +## Notas no funcionales + +**Plataformas**: solo iOS. Android no ve nada de esto. + +**Compatibilidad**: **BREAKING** para cualquier host Swift ya integrado — en teoría. Es la razón +de ser del bump a 1.2.0 y de que esto no se coló en el 003, que se declaró explícitamente +*«no cambiar la API pública»*. + +**Orden**: depende del spec 006, que añade `signOutHandler` al mismo fichero. Debe implementarse +sobre `feature/010-ios-build-hygiene` o posterior; hacerlo antes obliga a rehacer el trabajo cuando +006 aterrice. + +**Coordinación**: comprobado antes de implementar — **ningún consumidor toca esta API**. Ni Fledge ni +Paparcar nombran `GoogleSignInProviderIOS` en Kotlin ni en Swift, así que este spec por sí solo no +obliga a ningún cambio río abajo. Lo que sí se lo obliga es el renombrado del paquete que viaja en +la misma 2.0.0. + +**Requisitos externos**: ninguno. No toca entitlements, consola de Firebase ni SPM.