From 7d858abf9dc0ccd917a3b832990accb68235afa1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 18 Feb 2026 21:37:38 -0600 Subject: [PATCH] Unify sharing codec and wire iOS KMP actuals --- .../sharing/ContractorSharingManager.kt | 37 +----- .../casera/sharing/ResidenceSharingManager.kt | 25 +--- .../example/casera/models/CaseraShareCodec.kt | 70 ++++++++++ .../com/example/casera/network/DocumentApi.kt | 5 +- .../casera/platform/ContractorSharing.ios.kt | 58 ++++++-- .../casera/platform/ResidenceSharing.ios.kt | 91 +++++++++++-- .../casera/storage/TokenManager.ios.kt | 16 +-- iosApp/iosApp.xcodeproj/project.pbxproj | 6 + .../Contractor/ContractorSharingManager.swift | 26 +--- iosApp/iosApp/Info.plist | 2 + iosApp/iosApp/Login/GoogleSignInManager.swift | 124 +++++++++++++++--- .../Residence/ResidenceSharingManager.swift | 21 +-- 12 files changed, 346 insertions(+), 135 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/models/CaseraShareCodec.kt diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt index 9376d3c..cf59d87 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ContractorSharingManager.kt @@ -5,16 +5,12 @@ import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import com.example.casera.data.DataManager +import com.example.casera.models.CaseraShareCodec import com.example.casera.models.Contractor -import com.example.casera.models.SharedContractor -import com.example.casera.models.resolveSpecialtyIds -import com.example.casera.models.toCreateRequest -import com.example.casera.models.toSharedContractor import com.example.casera.network.APILayer import com.example.casera.network.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import java.io.File /** @@ -22,12 +18,6 @@ import java.io.File */ object ContractorSharingManager { - private val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - encodeDefaults = true - } - /** * Creates a share Intent for a contractor. * The contractor data is written to a temporary .casera file and shared via FileProvider. @@ -39,16 +29,8 @@ object ContractorSharingManager { fun createShareIntent(context: Context, contractor: Contractor): Intent? { return try { val currentUsername = DataManager.currentUser.value?.username ?: "Unknown" - val sharedContractor = contractor.toSharedContractor(currentUsername) - - val jsonString = json.encodeToString(SharedContractor.serializer(), sharedContractor) - - // Create safe filename - val safeName = contractor.name - .replace(" ", "_") - .replace("/", "-") - .take(50) - val fileName = "${safeName}.casera" + val jsonString = CaseraShareCodec.encodeContractorPackage(contractor, currentUsername) + val fileName = CaseraShareCodec.safeShareFileName(contractor.name) // Create shared directory val shareDir = File(context.cacheDir, "shared") @@ -97,15 +79,10 @@ object ContractorSharingManager { val jsonString = inputStream.bufferedReader().use { it.readText() } inputStream.close() - // Parse JSON - val sharedContractor = json.decodeFromString(SharedContractor.serializer(), jsonString) - - // Resolve specialty names to IDs - val specialties = DataManager.contractorSpecialties.value - val specialtyIds = sharedContractor.resolveSpecialtyIds(specialties) - - // Create the request - val createRequest = sharedContractor.toCreateRequest(specialtyIds) + val createRequest = CaseraShareCodec.createContractorImportRequestOrNull( + jsonContent = jsonString, + availableSpecialties = DataManager.contractorSpecialties.value + ) ?: return@withContext ApiResult.Error("Invalid contractor share package") // Call API APILayer.createContractor(createRequest) diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt index 667bd6c..4faf6d5 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/sharing/ResidenceSharingManager.kt @@ -5,14 +5,13 @@ import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import com.example.casera.data.DataManager +import com.example.casera.models.CaseraShareCodec import com.example.casera.models.JoinResidenceResponse import com.example.casera.models.Residence -import com.example.casera.models.SharedResidence import com.example.casera.network.APILayer import com.example.casera.network.ApiResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import java.io.File /** @@ -22,12 +21,6 @@ import java.io.File */ object ResidenceSharingManager { - private val json = Json { - prettyPrint = true - ignoreUnknownKeys = true - encodeDefaults = true - } - /** * Creates a share Intent for a residence. * This first calls the backend to generate a share code, then creates the file. @@ -45,14 +38,8 @@ object ResidenceSharingManager { when (result) { is ApiResult.Success -> { val sharedResidence = result.data - val jsonString = json.encodeToString(SharedResidence.serializer(), sharedResidence) - - // Create safe filename - val safeName = residence.name - .replace(" ", "_") - .replace("/", "-") - .take(50) - val fileName = "${safeName}.casera" + val jsonString = CaseraShareCodec.encodeSharedResidence(sharedResidence) + val fileName = CaseraShareCodec.safeShareFileName(residence.name) // Create shared directory val shareDir = File(context.cacheDir, "shared") @@ -108,11 +95,11 @@ object ResidenceSharingManager { val jsonString = inputStream.bufferedReader().use { it.readText() } inputStream.close() - // Parse JSON - val sharedResidence = json.decodeFromString(SharedResidence.serializer(), jsonString) + val shareCode = CaseraShareCodec.extractResidenceShareCodeOrNull(jsonString) + ?: return@withContext ApiResult.Error("Invalid residence share package") // Call API with share code - APILayer.joinWithCode(sharedResidence.shareCode) + APILayer.joinWithCode(shareCode) } catch (e: Exception) { e.printStackTrace() ApiResult.Error("Failed to join residence: ${e.message}") diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/CaseraShareCodec.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/CaseraShareCodec.kt new file mode 100644 index 0000000..2b1c9b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/CaseraShareCodec.kt @@ -0,0 +1,70 @@ +package com.example.casera.models + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Shared encoder/decoder for `.casera` payloads across Android and iOS. + * + * This keeps package JSON shape in one place while each platform owns + * native share-sheet presentation details. + */ +object CaseraShareCodec { + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun encodeContractorPackage(contractor: Contractor, exportedBy: String? = null): String { + return encodeSharedContractor(contractor.toSharedContractor(exportedBy)) + } + + fun encodeSharedContractor(sharedContractor: SharedContractor): String { + return json.encodeToString(SharedContractor.serializer(), sharedContractor) + } + + fun encodeSharedResidence(sharedResidence: SharedResidence): String { + return json.encodeToString(SharedResidence.serializer(), sharedResidence) + } + + fun decodeSharedContractorOrNull(jsonContent: String): SharedContractor? { + return try { + json.decodeFromString(SharedContractor.serializer(), jsonContent) + } catch (_: Exception) { + null + } + } + + fun decodeSharedResidenceOrNull(jsonContent: String): SharedResidence? { + return try { + json.decodeFromString(SharedResidence.serializer(), jsonContent) + } catch (_: Exception) { + null + } + } + + fun createContractorImportRequestOrNull( + jsonContent: String, + availableSpecialties: List + ): ContractorCreateRequest? { + val shared = decodeSharedContractorOrNull(jsonContent) ?: return null + val specialtyIds = shared.resolveSpecialtyIds(availableSpecialties) + return shared.toCreateRequest(specialtyIds) + } + + fun extractResidenceShareCodeOrNull(jsonContent: String): String? { + return decodeSharedResidenceOrNull(jsonContent)?.shareCode + } + + /** + * Build a filesystem-safe package filename with `.casera` extension. + */ + fun safeShareFileName(displayName: String): String { + val safeName = displayName + .replace(" ", "_") + .replace("/", "-") + .take(50) + return "$safeName.casera" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt index 75496d5..9ec5095 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/DocumentApi.kt @@ -15,6 +15,8 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { token: String, residenceId: Int? = null, documentType: String? = null, + // Deprecated filter args kept for source compatibility with iOS wrappers. + // Backend/OpenAPI currently support: residence, document_type, is_active, expiring_soon, search. category: String? = null, contractorId: Int? = null, isActive: Boolean? = null, @@ -27,11 +29,8 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) { header("Authorization", "Token $token") residenceId?.let { parameter("residence", it) } documentType?.let { parameter("document_type", it) } - category?.let { parameter("category", it) } - contractorId?.let { parameter("contractor", it) } isActive?.let { parameter("is_active", it) } expiringSoon?.let { parameter("expiring_soon", it) } - tags?.let { parameter("tags", it) } search?.let { parameter("search", it) } } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt index 157ff3b..60b357a 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ContractorSharing.ios.kt @@ -1,20 +1,56 @@ package com.example.casera.platform import androidx.compose.runtime.Composable +import androidx.compose.ui.interop.LocalUIViewController +import com.example.casera.data.DataManager +import com.example.casera.models.CaseraShareCodec import com.example.casera.models.Contractor +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.* +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIViewController -// Architecture Decision: iOS sharing is implemented natively in Swift -// (ContractorSharingManager.swift) because UIActivityViewController and -// other iOS-native sharing APIs cannot be driven from Kotlin Multiplatform. -// This is an intentional no-op stub. The Android implementation is in androidMain. - -/** - * iOS implementation is a no-op - sharing is handled in Swift layer via ContractorSharingManager.swift. - * The iOS ContractorDetailView uses the Swift sharing manager directly. - */ @Composable actual fun rememberShareContractor(): (Contractor) -> Unit { - return { _: Contractor -> - // No-op on iOS - sharing handled in Swift layer + val viewController = LocalUIViewController.current + + return share@{ contractor: Contractor -> + val currentUsername = DataManager.currentUser.value?.username ?: "Unknown" + val jsonContent = CaseraShareCodec.encodeContractorPackage(contractor, currentUsername) + val fileUrl = writeShareFile(jsonContent, contractor.name) ?: return@share + presentShareSheet(viewController, fileUrl) } } + +@OptIn(ExperimentalForeignApi::class) +private fun writeShareFile(jsonContent: String, displayName: String): NSURL? { + val fileName = CaseraShareCodec.safeShareFileName(displayName) + val filePath = NSTemporaryDirectory().plus(fileName) + + val bytes = jsonContent.encodeToByteArray() + val data = bytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + } + val didCreate = NSFileManager.defaultManager.createFileAtPath( + path = filePath, + contents = data, + attributes = null + ) + if (!didCreate) return null + + return NSURL.fileURLWithPath(filePath) +} + +private fun presentShareSheet(viewController: UIViewController, fileUrl: NSURL) { + val activityViewController = UIActivityViewController( + activityItems = listOf(fileUrl), + applicationActivities = null + ) + viewController.presentViewController( + viewControllerToPresent = activityViewController, + animated = true, + completion = null + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt index 9aca2d5..8fa95ac 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/ResidenceSharing.ios.kt @@ -1,20 +1,89 @@ package com.example.casera.platform import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.interop.LocalUIViewController +import com.example.casera.models.CaseraShareCodec import com.example.casera.models.Residence +import com.example.casera.network.APILayer +import com.example.casera.network.ApiResult +import kotlinx.coroutines.launch +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.* +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIViewController -// Architecture Decision: iOS sharing is implemented natively in Swift -// (ResidenceSharingManager.swift) because UIActivityViewController and -// other iOS-native sharing APIs cannot be driven from Kotlin Multiplatform. -// This is an intentional no-op stub. The Android implementation is in androidMain. - -/** - * iOS implementation is a no-op - sharing is handled in Swift layer via ResidenceSharingManager.swift. - */ @Composable actual fun rememberShareResidence(): Pair Unit> { - val state = remember { ResidenceSharingState() } - val noOp: (Residence) -> Unit = { /* No-op on iOS - handled in Swift layer */ } - return Pair(state, noOp) + val viewController = LocalUIViewController.current + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf(ResidenceSharingState()) } + + val shareFunction: (Residence) -> Unit = share@{ residence -> + scope.launch { + state = ResidenceSharingState(isLoading = true) + + when (val result = APILayer.generateSharePackage(residence.id)) { + is ApiResult.Success -> { + val jsonContent = CaseraShareCodec.encodeSharedResidence(result.data) + val fileUrl = writeShareFile(jsonContent, residence.name) + if (fileUrl == null) { + state = ResidenceSharingState(isLoading = false, error = "Failed to create share package") + return@launch + } + + state = ResidenceSharingState(isLoading = false) + presentShareSheet(viewController, fileUrl) + } + is ApiResult.Error -> { + state = ResidenceSharingState( + isLoading = false, + error = result.message.ifBlank { "Failed to generate share package" } + ) + } + else -> { + state = ResidenceSharingState(isLoading = false, error = "Failed to generate share package") + } + } + } + } + + return Pair(state, shareFunction) +} + +@OptIn(ExperimentalForeignApi::class) +private fun writeShareFile(jsonContent: String, displayName: String): NSURL? { + val fileName = CaseraShareCodec.safeShareFileName(displayName) + val filePath = NSTemporaryDirectory().plus(fileName) + + val bytes = jsonContent.encodeToByteArray() + val data = bytes.usePinned { pinned -> + NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) + } + val didCreate = NSFileManager.defaultManager.createFileAtPath( + path = filePath, + contents = data, + attributes = null + ) + if (!didCreate) return null + + return NSURL.fileURLWithPath(filePath) +} + +private fun presentShareSheet(viewController: UIViewController, fileUrl: NSURL) { + val activityViewController = UIActivityViewController( + activityItems = listOf(fileUrl), + applicationActivities = null + ) + viewController.presentViewController( + viewControllerToPresent = activityViewController, + animated = true, + completion = null + ) } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt index f291118..6f271dc 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt @@ -1,6 +1,5 @@ package com.example.casera.storage -import platform.Foundation.NSUserDefaults import kotlin.concurrent.Volatile /** @@ -20,13 +19,12 @@ interface KeychainDelegate { * iOS implementation of TokenManager. * * Uses iOS Keychain via [KeychainDelegate] for secure token storage. - * Falls back to NSUserDefaults if delegate is not set (should not happen - * in production — delegate is set in iOSApp.init before DataManager init). + * If delegate is missing, operations fail closed (no insecure fallback). * * On first read, migrates any existing NSUserDefaults token to Keychain. */ actual class TokenManager { - private val prefs = NSUserDefaults.standardUserDefaults + private val prefs = platform.Foundation.NSUserDefaults.standardUserDefaults actual fun saveToken(token: String) { val delegate = keychainDelegate @@ -36,9 +34,8 @@ actual class TokenManager { prefs.removeObjectForKey(TOKEN_KEY) prefs.synchronize() } else { - // Fallback (should not happen in production) - prefs.setObject(token, forKey = TOKEN_KEY) - prefs.synchronize() + // Fail closed: never store auth tokens in insecure storage. + println("TokenManager: Keychain delegate not set, refusing to save token insecurely") } } @@ -62,8 +59,9 @@ actual class TokenManager { return null } - // Fallback to NSUserDefaults (should not happen in production) - return prefs.stringForKey(TOKEN_KEY) + // Fail closed: no insecure fallback reads. + println("TokenManager: Keychain delegate not set, refusing insecure token read") + return null } actual fun clearToken() { diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 84317ea..871df48 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -768,6 +768,7 @@ 1C685CD92EC5539000A9669B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -779,11 +780,13 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Casera; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera"; }; name = Debug; @@ -791,6 +794,7 @@ 1C685CDA2EC5539000A9669B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ARCHS = arm64; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -802,11 +806,13 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Casera; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera"; }; name = Release; diff --git a/iosApp/iosApp/Contractor/ContractorSharingManager.swift b/iosApp/iosApp/Contractor/ContractorSharingManager.swift index 1ee9fa9..ab01410 100644 --- a/iosApp/iosApp/Contractor/ContractorSharingManager.swift +++ b/iosApp/iosApp/Contractor/ContractorSharingManager.swift @@ -19,12 +19,6 @@ class ContractorSharingManager: ObservableObject { // MARK: - Private Properties - private let jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - return encoder - }() - private let jsonDecoder = JSONDecoder() private init() {} @@ -38,23 +32,17 @@ class ContractorSharingManager: ObservableObject { // Get current username for export metadata let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown" - // Convert Contractor to SharedContractor using Kotlin extension - let sharedContractor = contractor.toSharedContractor(exportedBy: currentUsername) + let jsonContent = CaseraShareCodec.shared.encodeContractorPackage( + contractor: contractor, + exportedBy: currentUsername + ) - // Create Swift-compatible structure for JSON encoding - let exportData = SharedContractorExport(from: sharedContractor) - - guard let jsonData = try? jsonEncoder.encode(exportData) else { - print("ContractorSharingManager: Failed to encode contractor to JSON") + guard let jsonData = jsonContent.data(using: .utf8) else { + print("ContractorSharingManager: Failed to encode contractor package as UTF-8") return nil } - // Create a safe filename - let safeName = contractor.name - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "/", with: "-") - .prefix(50) - let fileName = "\(safeName).casera" + let fileName = CaseraShareCodec.shared.safeShareFileName(displayName: contractor.name) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) do { diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index c1c39ae..72defe6 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -12,6 +12,8 @@ com.example.casera.pro.annual CASERA_IAP_MONTHLY_PRODUCT_ID com.example.casera.pro.monthly + CASERA_GOOGLE_WEB_CLIENT_ID + CFBundleDocumentTypes diff --git a/iosApp/iosApp/Login/GoogleSignInManager.swift b/iosApp/iosApp/Login/GoogleSignInManager.swift index 5837d65..c6aa224 100644 --- a/iosApp/iosApp/Login/GoogleSignInManager.swift +++ b/iosApp/iosApp/Login/GoogleSignInManager.swift @@ -1,5 +1,8 @@ import Foundation import AuthenticationServices +import CryptoKit +import Security +import UIKit import ComposeApp /// Handles Google OAuth flow using ASWebAuthenticationSession. @@ -15,14 +18,18 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication var onSignInSuccess: ((Bool) -> Void)? private var webAuthSession: ASWebAuthenticationSession? + private var codeVerifier: String? + private var oauthState: String? + + private let redirectScheme = "casera" + private var redirectURI: String { "\(redirectScheme):/oauth2callback" } // MARK: - Public func signIn() { guard !isLoading else { return } - let clientId = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID - guard ApiConfig.shared.isGoogleSignInConfigured else { + guard let clientId = resolvedClientID() else { errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required." return } @@ -30,9 +37,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication isLoading = true errorMessage = nil + // PKCE + state are required for secure native OAuth. + let verifier = Self.randomURLSafeString(length: 64) + let challenge = Self.sha256Base64URL(verifier) + let state = Self.randomURLSafeString(length: 32) + codeVerifier = verifier + oauthState = state + // Build Google OAuth URL - let redirectScheme = "com.tt.casera" - let redirectURI = "\(redirectScheme):/oauth2callback" var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! components.queryItems = [ URLQueryItem(name: "client_id", value: clientId), @@ -41,6 +53,9 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication URLQueryItem(name: "scope", value: "openid email profile"), URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "prompt", value: "select_account"), + URLQueryItem(name: "code_challenge", value: challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "state", value: state), ] guard let authURL = components.url else { @@ -57,6 +72,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication guard let self else { return } if let error { + self.resetOAuthState() self.isLoading = false // Don't show error for user cancellation if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue { @@ -66,14 +82,42 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication } guard let callbackURL, - let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), - let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { self.isLoading = false + self.resetOAuthState() self.errorMessage = "Failed to get authorization code from Google" return } - await self.exchangeCodeForToken(code: code, redirectURI: redirectURI, clientId: clientId) + if let oauthError = components.queryItems?.first(where: { $0.name == "error" })?.value { + self.isLoading = false + self.resetOAuthState() + self.errorMessage = "Google Sign-In error: \(oauthError)" + return + } + + guard let callbackState = components.queryItems?.first(where: { $0.name == "state" })?.value, + callbackState == self.oauthState else { + self.isLoading = false + self.resetOAuthState() + self.errorMessage = "Invalid Google OAuth state. Please try again." + return + } + + guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value, + let verifier = self.codeVerifier else { + self.isLoading = false + self.resetOAuthState() + self.errorMessage = "Failed to get authorization code from Google" + return + } + + await self.exchangeCodeForToken( + code: code, + redirectURI: self.redirectURI, + clientId: clientId, + codeVerifier: verifier + ) } } @@ -86,16 +130,23 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication // MARK: - ASWebAuthenticationPresentationContextProviding nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - // Return the key window for presentation - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene - return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor() + MainActor.assumeIsolated { + // Return the key window for presentation + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene + return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor() + } } // MARK: - Private /// Exchange authorization code for ID token via Google's token endpoint - private func exchangeCodeForToken(code: String, redirectURI: String, clientId: String) async { + private func exchangeCodeForToken( + code: String, + redirectURI: String, + clientId: String, + codeVerifier: String + ) async { let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! var request = URLRequest(url: tokenURL) request.httpMethod = "POST" @@ -106,16 +157,18 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication "client_id": clientId, "redirect_uri": redirectURI, "grant_type": "authorization_code", + "code_verifier": codeVerifier, ] - request.httpBody = body - .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } - .joined(separator: "&") - .data(using: .utf8) + + var encoded = URLComponents() + encoded.queryItems = body.map { URLQueryItem(name: $0.key, value: $0.value) } + request.httpBody = encoded.percentEncodedQuery?.data(using: .utf8) do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + resetOAuthState() isLoading = false errorMessage = "Failed to exchange authorization code" return @@ -123,6 +176,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let idToken = json["id_token"] as? String else { + resetOAuthState() isLoading = false errorMessage = "Failed to get ID token from Google" return @@ -131,6 +185,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication // Send ID token to backend await sendToBackend(idToken: idToken) } catch { + resetOAuthState() isLoading = false errorMessage = "Network error: \(error.localizedDescription)" } @@ -142,12 +197,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication let result = try? await APILayer.shared.googleSignIn(request: request) guard let result else { + resetOAuthState() isLoading = false errorMessage = "Sign in failed. Please try again." return } if let success = result as? ApiResultSuccess, let response = success.data { + resetOAuthState() isLoading = false // Share token and API URL with widget extension @@ -161,11 +218,46 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication onSignInSuccess?(response.user.verified) } else if let error = ApiResultBridge.error(from: result) { + resetOAuthState() isLoading = false errorMessage = ErrorMessageParser.parse(error.message) } else { + resetOAuthState() isLoading = false errorMessage = "Sign in failed. Please try again." } } + + private func resolvedClientID() -> String? { + if let fromInfo = Bundle.main.object(forInfoDictionaryKey: "CASERA_GOOGLE_WEB_CLIENT_ID") as? String { + let trimmed = fromInfo.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + + let fromShared = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID.trimmingCharacters(in: .whitespacesAndNewlines) + return fromShared.isEmpty ? nil : fromShared + } + + private func resetOAuthState() { + codeVerifier = nil + oauthState = nil + } + + private static func randomURLSafeString(length: Int) -> String { + let allowed = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") + var bytes = [UInt8](repeating: 0, count: length) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return String(bytes.map { allowed[Int($0) % allowed.count] }) + } + + private static func sha256Base64URL(_ input: String) -> String { + let digest = SHA256.hash(data: Data(input.utf8)) + return Data(digest) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } } diff --git a/iosApp/iosApp/Residence/ResidenceSharingManager.swift b/iosApp/iosApp/Residence/ResidenceSharingManager.swift index 3eca8c9..c105614 100644 --- a/iosApp/iosApp/Residence/ResidenceSharingManager.swift +++ b/iosApp/iosApp/Residence/ResidenceSharingManager.swift @@ -29,12 +29,6 @@ class ResidenceSharingManager: ObservableObject { // MARK: - Private Properties - private let jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - return encoder - }() - private let jsonDecoder = JSONDecoder() private init() {} @@ -70,21 +64,14 @@ class ResidenceSharingManager: ObservableObject { return nil } - // Create Swift-compatible structure for JSON encoding - let exportData = SharedResidenceExport(from: sharedResidence) - - guard let jsonData = try? jsonEncoder.encode(exportData) else { - print("ResidenceSharingManager: Failed to encode residence to JSON") + let jsonContent = CaseraShareCodec.shared.encodeSharedResidence(sharedResidence: sharedResidence) + guard let jsonData = jsonContent.data(using: .utf8) else { + print("ResidenceSharingManager: Failed to encode residence package as UTF-8") errorMessage = "Failed to create share file" return nil } - // Create a safe filename - let safeName = residence.name - .replacingOccurrences(of: " ", with: "_") - .replacingOccurrences(of: "/", with: "-") - .prefix(50) - let fileName = "\(safeName).casera" + let fileName = CaseraShareCodec.shared.safeShareFileName(displayName: residence.name) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) do {