Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
```

Expand Down
46 changes: 34 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

---

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 })
Expand All @@ -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()
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions iosApp/iosApp/iOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading