Phase 3A: Shared Authentication Foundation

Date: 2025-11-19 Status: Complete Week: 28


Overview

Phase 3A establishes the platform-agnostic authentication foundation for the Archery Apprentice iOS development. This phase creates shared authentication logic that works identically on both Android and iOS through Kotlin Multiplatform (KMP).

What Phase 3A Delivers:

  • Domain models for authentication state and user data
  • Repository pattern with Firebase Auth implementation
  • Anonymous sign-in support (foundation for Phase 3B/3C)
  • Cross-platform authentication using GitLive Firebase KMP SDK
  • 100% shared authentication code (zero platform-specific auth logic)

Future Phases:

  • Phase 3B: Google Sign-In on iOS
  • Phase 3C: Apple Sign-In
  • Phase 3D: Sign-out and account management

Architecture

High-Level Design

Phase 3A follows a clean architecture pattern with clear separation between domain, data, and platform layers:

┌─────────────────────────────────────────────────────┐
│                  Platform Layer                      │
│          (Android Activity, iOS SwiftUI)             │
│                                                       │
│  Android: Composables observe AuthState              │
│  iOS: SwiftUI views observe AuthState                │
└─────────────────┬───────────────────────────────────┘
                  │
                  │ Flow<AuthState>
                  ▼
┌─────────────────────────────────────────────────────┐
│               Presentation Layer                     │
│              (Shared Presenters)                     │
│                                                       │
│  AuthPresenter: Manages auth UI state                │
│  Observes repository, emits UI-ready states          │
└─────────────────┬───────────────────────────────────┘
                  │
                  │ Repository Interface
                  ▼
┌─────────────────────────────────────────────────────┐
│                  Domain Layer                        │
│            (shared:domain module)                    │
│                                                       │
│  ┌──────────────────────────────────────┐            │
│  │ Models:                              │            │
│  │ - AuthUser (domain user model)       │            │
│  │ - AuthState (sealed class)           │            │
│  │ - AuthResult (result wrapper)        │            │
│  │ - AuthException (error types)        │            │
│  └──────────────────────────────────────┘            │
│                                                       │
│  ┌──────────────────────────────────────┐            │
│  │ Contracts:                           │            │
│  │ - AuthRepository (interface)         │            │
│  └──────────────────────────────────────┘            │
└─────────────────┬───────────────────────────────────┘
                  │
                  │ Interface implementation
                  ▼
┌─────────────────────────────────────────────────────┐
│                   Data Layer                         │
│             (shared:data module)                     │
│                                                       │
│  ┌──────────────────────────────────────┐            │
│  │ FirebaseAuthRepository               │            │
│  │ (GitLive KMP SDK)                    │            │
│  │                                      │            │
│  │ - signInAnonymously()                │            │
│  │ - observeAuthState()                 │            │
│  │ - getCurrentUser()                   │            │
│  │ - signOut()                          │            │
│  └──────────────────────────────────────┘            │
│                                                       │
│  ┌──────────────────────────────────────┐            │
│  │ AuthMappers                          │            │
│  │ (Firebase → Domain conversion)       │            │
│  │                                      │            │
│  │ Firebase.User → AuthUser             │            │
│  └──────────────────────────────────────┘            │
└─────────────────┬───────────────────────────────────┘
                  │
                  │ GitLive Firebase KMP
                  ▼
┌─────────────────────────────────────────────────────┐
│              Firebase (Platform SDKs)                │
│                                                       │
│  Android: Firebase Android SDK                       │
│  iOS: Firebase iOS SDK (CocoaPods)                   │
│                                                       │
│  Initialized per platform:                           │
│  - Android: Automatic (google-services.json)         │
│  - iOS: Manual (FirebaseApp.configure())             │
└─────────────────────────────────────────────────────┘

Key Design Decisions

1. GitLive Firebase KMP SDK

Decision: Use GitLive Firebase KMP SDK for cross-platform Firebase access.

Rationale:

  • Enables 100% shared authentication code
  • Maintains access to full Firebase Auth feature set
  • Wraps native Firebase SDKs (not a reimplementation)
  • Proven in production (already used for Firestore in project)

Trade-offs:

  • API differences from native Firebase SDKs (minor)
  • Additional dependency (GitLive wrapper)
  • Requires native Firebase initialization on each platform

Outcome: ✅ Successful - Already validated with Firestore integration in Week 28

2. Sealed Classes for State Management

Decision: Use Kotlin sealed classes for AuthState and AuthResult hierarchies.

Pattern:

sealed class AuthState {
    data object Loading : AuthState()
    data class Authenticated(val user: AuthUser) : AuthState()
    data object Unauthenticated : AuthState()
    data class Error(val exception: AuthException) : AuthState()
}
 
sealed class AuthResult {
    data class Success(val user: AuthUser) : AuthResult()
    data class Error(val exception: AuthException) : AuthResult()
}

Benefits:

  • Type-safe state representation
  • Exhaustive when expressions (compiler-enforced)
  • Clear API for consumers
  • Easy to mock in tests

Outcome: Clean, self-documenting state management with compile-time safety

3. Repository Pattern

Decision: Abstract Firebase implementation behind AuthRepository interface in domain layer.

Pattern:

// Domain layer (interface)
interface AuthRepository {
    fun observeAuthState(): Flow<AuthState>
    suspend fun signInAnonymously(): AuthResult
    suspend fun signOut(): AuthResult
}
 
// Data layer (implementation)
class FirebaseAuthRepository(
    private val firebaseAuth: FirebaseAuth  // GitLive SDK
) : AuthRepository {
    // Implementation using GitLive Firebase KMP
}

Benefits:

  • Domain layer independent of Firebase
  • Easy to mock for testing
  • Future-proof (can swap implementations)
  • Follows KMP best practices

Outcome: Clean separation of concerns, testable domain logic

4. Expect/Actual Pattern for Firebase Initialization

Decision: Use Kotlin’s expect/actual mechanism for platform-specific Firebase setup.

Pattern:

// commonMain (expect)
expect object FirebaseInitializer {
    fun isInitialized(): Boolean
}
 
// androidMain (actual)
actual object FirebaseInitializer {
    actual fun isInitialized(): Boolean {
        return FirebaseApp.getApps().isNotEmpty()
    }
}
 
// iosMain (actual)
actual object FirebaseInitializer {
    actual fun isInitialized(): Boolean {
        return FirebaseApp.app() != null
    }
}

Benefits:

  • Platform-specific initialization logic encapsulated
  • Shared code can verify Firebase readiness
  • Type-safe platform abstractions

Outcome: Clean, maintainable platform initialization


Domain Layer Implementation

AuthUser Model

File: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/models/auth/AuthUser.kt

/**
 * Platform-agnostic user model.
 * Represents an authenticated user across Android and iOS.
 */
data class AuthUser(
    val uid: String,
    val email: String?,
    val displayName: String?,
    val photoUrl: String?,
    val isAnonymous: Boolean,
    val emailVerified: Boolean
)

Design notes:

  • Minimal data class (no Firebase types)
  • Nullable fields match Firebase Auth API
  • isAnonymous distinguishes anonymous from authenticated users

AuthState Sealed Class

File: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/models/auth/AuthState.kt

/**
 * Authentication state hierarchy.
 * Represents all possible authentication states in the app.
 */
sealed class AuthState {
    /**
     * Initial state or auth check in progress.
     */
    data object Loading : AuthState()
 
    /**
     * User is authenticated (anonymous or with credentials).
     */
    data class Authenticated(val user: AuthUser) : AuthState()
 
    /**
     * No user signed in.
     */
    data object Unauthenticated : AuthState()
 
    /**
     * Authentication error occurred.
     */
    data class Error(val exception: AuthException) : AuthState()
}

Usage in UI:

// Android Composable
@Composable
fun AuthScreen(authState: AuthState) {
    when (authState) {
        is AuthState.Loading -> LoadingIndicator()
        is AuthState.Authenticated -> HomeScreen(authState.user)
        is AuthState.Unauthenticated -> SignInScreen()
        is AuthState.Error -> ErrorScreen(authState.exception)
    }
}
// iOS SwiftUI
struct AuthView: View {
    @State var authState: AuthState
 
    var body: some View {
        switch authState {
        case .loading:
            ProgressView()
        case .authenticated(let user):
            HomeView(user: user)
        case .unauthenticated:
            SignInView()
        case .error(let exception):
            ErrorView(exception: exception)
        }
    }
}

AuthRepository Interface

File: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/repositories/AuthRepository.kt

/**
 * Authentication repository contract.
 * Defines operations for user authentication.
 */
interface AuthRepository {
    /**
     * Observe authentication state changes.
     * Emits whenever user signs in, signs out, or token refreshes.
     */
    fun observeAuthState(): Flow<AuthState>
 
    /**
     * Sign in anonymously.
     * Used for temporary access before full authentication.
     */
    suspend fun signInAnonymously(): AuthResult
 
    /**
     * Get current authenticated user.
     * Returns null if no user signed in.
     */
    suspend fun getCurrentUser(): AuthUser?
 
    /**
     * Sign out current user.
     */
    suspend fun signOut(): AuthResult
}

Data Layer Implementation

FirebaseAuthRepository

File: shared/data/src/commonMain/kotlin/com/archeryapprentice/data/repositories/FirebaseAuthRepository.kt

/**
 * Firebase authentication implementation using GitLive KMP SDK.
 */
class FirebaseAuthRepository(
    private val firebaseAuth: FirebaseAuth  // GitLive SDK
) : AuthRepository {
 
    override fun observeAuthState(): Flow<AuthState> {
        return firebaseAuth.authStateChanged
            .map { firebaseUser ->
                when {
                    firebaseUser == null -> AuthState.Unauthenticated
                    else -> AuthState.Authenticated(firebaseUser.toAuthUser())
                }
            }
            .catch { exception ->
                emit(AuthState.Error(AuthException.Unknown(exception)))
            }
    }
 
    override suspend fun signInAnonymously(): AuthResult {
        return try {
            val authResult = firebaseAuth.signInAnonymously()
            val user = authResult.user
                ?: return AuthResult.Error(AuthException.SignInFailed("No user returned"))
 
            AuthResult.Success(user.toAuthUser())
        } catch (e: Exception) {
            AuthResult.Error(AuthException.SignInFailed(e.message ?: "Unknown error"))
        }
    }
 
    override suspend fun getCurrentUser(): AuthUser? {
        return firebaseAuth.currentUser?.toAuthUser()
    }
 
    override suspend fun signOut(): AuthResult {
        return try {
            firebaseAuth.signOut()
            AuthResult.Success(null)  // No user after sign out
        } catch (e: Exception) {
            AuthResult.Error(AuthException.SignOutFailed(e.message ?: "Unknown error"))
        }
    }
}

Auth Mappers

File: shared/data/src/commonMain/kotlin/com/archeryapprentice/data/mappers/AuthMappers.kt

/**
 * Extension function to convert Firebase User to domain AuthUser.
 */
fun FirebaseUser.toAuthUser(): AuthUser {
    return AuthUser(
        uid = uid,
        email = email,
        displayName = displayName,
        photoUrl = photoURL,
        isAnonymous = isAnonymous,
        emailVerified = isEmailVerified
    )
}

Design notes:

  • Simple mapping function (no complex logic)
  • Handles nullable fields from Firebase
  • Single responsibility (Firebase → Domain conversion)

Testing Strategy

Unit Tests

Domain layer tests:

  • ✅ AuthState sealed class hierarchy (exhaustiveness)
  • ✅ AuthUser data class validation
  • ✅ AuthException creation and messages

Data layer tests:

  • ✅ FirebaseAuthRepository with mocked GitLive SDK
  • ✅ Auth mapper conversions (Firebase → Domain)
  • ✅ Error handling (network failures, invalid credentials)
  • ✅ State flow emissions (sign in, sign out, token refresh)

Test utilities:

  • MockAuthRepository - In-memory auth for UI tests
  • TestAuthDataBuilder - Test data creation helpers

Integration Tests

Planned (Phase 3B):

  • Firebase Emulator integration tests
  • End-to-end authentication flows
  • Multi-platform auth verification (Android + iOS)

Code Examples

Observing Auth State (Shared Presenter)

// Shared presenter (presentation layer)
class AuthPresenter(
    private val authRepository: AuthRepository,
    presenterScope: CoroutineScope
) {
    private val _authState = MutableStateFlow<AuthState>(AuthState.Loading)
    val authState: StateFlow<AuthState> = _authState.asStateFlow()
 
    init {
        presenterScope.launch {
            authRepository.observeAuthState()
                .collect { state ->
                    _authState.value = state
                }
        }
    }
 
    fun signInAnonymously() {
        presenterScope.launch {
            _authState.value = AuthState.Loading
            when (val result = authRepository.signInAnonymously()) {
                is AuthResult.Success -> {
                    // State will update via observeAuthState()
                }
                is AuthResult.Error -> {
                    _authState.value = AuthState.Error(result.exception)
                }
            }
        }
    }
}

Android Composable

@Composable
fun SignInScreen(authPresenter: AuthPresenter) {
    val authState by authPresenter.authState.collectAsState()
 
    when (authState) {
        is AuthState.Loading -> {
            CircularProgressIndicator()
        }
        is AuthState.Unauthenticated -> {
            Button(onClick = { authPresenter.signInAnonymously() }) {
                Text("Sign In Anonymously")
            }
        }
        is AuthState.Authenticated -> {
            // Navigate to home screen
        }
        is AuthState.Error -> {
            Text("Error: ${(authState as AuthState.Error).exception.message}")
        }
    }
}

iOS SwiftUI View

import SwiftUI
import Shared  // KMP framework
 
struct SignInView: View {
    @StateObject private var authPresenter: AuthPresenter
 
    var body: some View {
        VStack {
            // Observe Kotlin Flow as AsyncSequence
            switch authPresenter.authState.value {
            case is AuthStateLoading:
                ProgressView()
 
            case is AuthStateUnauthenticated:
                Button("Sign In Anonymously") {
                    authPresenter.signInAnonymously()
                }
 
            case let authenticated as AuthStateAuthenticated:
                Text("Signed in as: \(authenticated.user.displayName ?? "User")")
 
            case let error as AuthStateError:
                Text("Error: \(error.exception.message)")
            }
        }
    }
}

Lessons Learned

Agent Implementation Experience

Agent 1 (Domain Models):

  • ✅ Sealed classes provide excellent type safety
  • ✅ Minimal data classes reduce coupling
  • ⚠️ AuthException hierarchy needs careful design (avoid over-engineering)

Agent 2 (Data Layer):

  • ✅ GitLive SDK API is intuitive and well-documented
  • ✅ Mapping Firebase → Domain is straightforward
  • ⚠️ Error handling requires wrapping Firebase exceptions
  • ⚠️ authStateChanged flow is hot (starts immediately)

Agent 3 (Testing):

  • ✅ Repository interface makes mocking trivial
  • ✅ Sealed classes enable exhaustive test coverage
  • ⚠️ GitLive SDK requires mockk (mockito doesn’t support KMP well)

Common Pitfalls

1. Firebase initialization timing

  • Problem: Accessing Firebase before initialization causes crash
  • Solution: Verify FirebaseInitializer.isInitialized() before repository usage

2. Flow collection lifecycle

  • Problem: authStateChanged continues emitting after sign-out
  • Solution: Cancel collection when presenter is destroyed (use presenterScope)

3. Anonymous vs authenticated users

  • Problem: Treating anonymous users as “not signed in”
  • Solution: Use isAnonymous field to distinguish user types

Main Repository Files

Phase 3A implementations:

  • shared/domain/models/auth/ - Domain models (AuthUser, AuthState, etc.)
  • shared/data/repositories/FirebaseAuthRepository.kt - Firebase implementation
  • shared/data/mappers/AuthMappers.kt - Firebase → Domain mappers
  • docs/FIREBASE_AUTH_SETUP.md - Firebase configuration guide

Existing abstractions:

  • shared/domain/platform/AuthProvider.kt - Auth status interface (predates Phase 3A)

Vault Documentation

External Resources


Next Steps

Phase 3B: Google Sign-In on iOS

Deliverables:

  • Google Sign-In implementation (iOS + Android)
  • OAuth consent screen configuration
  • signInWithGoogle() method in AuthRepository
  • Google Sign-In button UI components

Estimated: 4-6 hours

Phase 3C: Apple Sign-In

Deliverables:

  • Apple Sign-In implementation (iOS only)
  • Apple Developer configuration
  • signInWithApple() method in AuthRepository
  • Apple Sign-In button (iOS native)

Estimated: 4-6 hours

Phase 3D: Sign-Out and Account Management

Deliverables:

  • Account settings screen
  • Sign-out functionality
  • Account deletion (with Firebase cleanup)
  • Profile management

Estimated: 6-8 hours


Document Version: 1.0 Created: 2025-11-19 Last Updated: 2025-11-19 Maintainer: Agent D (Documentation)