Unify sharing codec and wire iOS KMP actuals

This commit is contained in:
Trey t
2026-02-18 21:37:38 -06:00
parent 5e3596db77
commit 7d858abf9d
12 changed files with 346 additions and 135 deletions

View File

@@ -5,16 +5,12 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.example.casera.data.DataManager import com.example.casera.data.DataManager
import com.example.casera.models.CaseraShareCodec
import com.example.casera.models.Contractor 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.APILayer
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
/** /**
@@ -22,12 +18,6 @@ import java.io.File
*/ */
object ContractorSharingManager { object ContractorSharingManager {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
/** /**
* Creates a share Intent for a contractor. * Creates a share Intent for a contractor.
* The contractor data is written to a temporary .casera file and shared via FileProvider. * 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? { fun createShareIntent(context: Context, contractor: Contractor): Intent? {
return try { return try {
val currentUsername = DataManager.currentUser.value?.username ?: "Unknown" val currentUsername = DataManager.currentUser.value?.username ?: "Unknown"
val sharedContractor = contractor.toSharedContractor(currentUsername) val jsonString = CaseraShareCodec.encodeContractorPackage(contractor, currentUsername)
val fileName = CaseraShareCodec.safeShareFileName(contractor.name)
val jsonString = json.encodeToString(SharedContractor.serializer(), sharedContractor)
// Create safe filename
val safeName = contractor.name
.replace(" ", "_")
.replace("/", "-")
.take(50)
val fileName = "${safeName}.casera"
// Create shared directory // Create shared directory
val shareDir = File(context.cacheDir, "shared") val shareDir = File(context.cacheDir, "shared")
@@ -97,15 +79,10 @@ object ContractorSharingManager {
val jsonString = inputStream.bufferedReader().use { it.readText() } val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close() inputStream.close()
// Parse JSON val createRequest = CaseraShareCodec.createContractorImportRequestOrNull(
val sharedContractor = json.decodeFromString(SharedContractor.serializer(), jsonString) jsonContent = jsonString,
availableSpecialties = DataManager.contractorSpecialties.value
// Resolve specialty names to IDs ) ?: return@withContext ApiResult.Error("Invalid contractor share package")
val specialties = DataManager.contractorSpecialties.value
val specialtyIds = sharedContractor.resolveSpecialtyIds(specialties)
// Create the request
val createRequest = sharedContractor.toCreateRequest(specialtyIds)
// Call API // Call API
APILayer.createContractor(createRequest) APILayer.createContractor(createRequest)

View File

@@ -5,14 +5,13 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.example.casera.data.DataManager import com.example.casera.data.DataManager
import com.example.casera.models.CaseraShareCodec
import com.example.casera.models.JoinResidenceResponse import com.example.casera.models.JoinResidenceResponse
import com.example.casera.models.Residence import com.example.casera.models.Residence
import com.example.casera.models.SharedResidence
import com.example.casera.network.APILayer import com.example.casera.network.APILayer
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
/** /**
@@ -22,12 +21,6 @@ import java.io.File
*/ */
object ResidenceSharingManager { object ResidenceSharingManager {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
/** /**
* Creates a share Intent for a residence. * Creates a share Intent for a residence.
* This first calls the backend to generate a share code, then creates the file. * This first calls the backend to generate a share code, then creates the file.
@@ -45,14 +38,8 @@ object ResidenceSharingManager {
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
val sharedResidence = result.data val sharedResidence = result.data
val jsonString = json.encodeToString(SharedResidence.serializer(), sharedResidence) val jsonString = CaseraShareCodec.encodeSharedResidence(sharedResidence)
val fileName = CaseraShareCodec.safeShareFileName(residence.name)
// Create safe filename
val safeName = residence.name
.replace(" ", "_")
.replace("/", "-")
.take(50)
val fileName = "${safeName}.casera"
// Create shared directory // Create shared directory
val shareDir = File(context.cacheDir, "shared") val shareDir = File(context.cacheDir, "shared")
@@ -108,11 +95,11 @@ object ResidenceSharingManager {
val jsonString = inputStream.bufferedReader().use { it.readText() } val jsonString = inputStream.bufferedReader().use { it.readText() }
inputStream.close() inputStream.close()
// Parse JSON val shareCode = CaseraShareCodec.extractResidenceShareCodeOrNull(jsonString)
val sharedResidence = json.decodeFromString(SharedResidence.serializer(), jsonString) ?: return@withContext ApiResult.Error("Invalid residence share package")
// Call API with share code // Call API with share code
APILayer.joinWithCode(sharedResidence.shareCode) APILayer.joinWithCode(shareCode)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
ApiResult.Error("Failed to join residence: ${e.message}") ApiResult.Error("Failed to join residence: ${e.message}")

View File

@@ -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<ContractorSpecialty>
): 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"
}
}

View File

@@ -15,6 +15,8 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
token: String, token: String,
residenceId: Int? = null, residenceId: Int? = null,
documentType: String? = 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, category: String? = null,
contractorId: Int? = null, contractorId: Int? = null,
isActive: Boolean? = null, isActive: Boolean? = null,
@@ -27,11 +29,8 @@ class DocumentApi(private val client: HttpClient = ApiClient.httpClient) {
header("Authorization", "Token $token") header("Authorization", "Token $token")
residenceId?.let { parameter("residence", it) } residenceId?.let { parameter("residence", it) }
documentType?.let { parameter("document_type", it) } documentType?.let { parameter("document_type", it) }
category?.let { parameter("category", it) }
contractorId?.let { parameter("contractor", it) }
isActive?.let { parameter("is_active", it) } isActive?.let { parameter("is_active", it) }
expiringSoon?.let { parameter("expiring_soon", it) } expiringSoon?.let { parameter("expiring_soon", it) }
tags?.let { parameter("tags", it) }
search?.let { parameter("search", it) } search?.let { parameter("search", it) }
} }

View File

@@ -1,20 +1,56 @@
package com.example.casera.platform package com.example.casera.platform
import androidx.compose.runtime.Composable 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 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 @Composable
actual fun rememberShareContractor(): (Contractor) -> Unit { actual fun rememberShareContractor(): (Contractor) -> Unit {
return { _: Contractor -> val viewController = LocalUIViewController.current
// No-op on iOS - sharing handled in Swift layer
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
)
}

View File

@@ -1,20 +1,89 @@
package com.example.casera.platform package com.example.casera.platform
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.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 @Composable
actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> { actual fun rememberShareResidence(): Pair<ResidenceSharingState, (Residence) -> Unit> {
val state = remember { ResidenceSharingState() } val viewController = LocalUIViewController.current
val noOp: (Residence) -> Unit = { /* No-op on iOS - handled in Swift layer */ } val scope = rememberCoroutineScope()
return Pair(state, noOp) 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
)
} }

View File

@@ -1,6 +1,5 @@
package com.example.casera.storage package com.example.casera.storage
import platform.Foundation.NSUserDefaults
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
/** /**
@@ -20,13 +19,12 @@ interface KeychainDelegate {
* iOS implementation of TokenManager. * iOS implementation of TokenManager.
* *
* Uses iOS Keychain via [KeychainDelegate] for secure token storage. * Uses iOS Keychain via [KeychainDelegate] for secure token storage.
* Falls back to NSUserDefaults if delegate is not set (should not happen * If delegate is missing, operations fail closed (no insecure fallback).
* in production — delegate is set in iOSApp.init before DataManager init).
* *
* On first read, migrates any existing NSUserDefaults token to Keychain. * On first read, migrates any existing NSUserDefaults token to Keychain.
*/ */
actual class TokenManager { actual class TokenManager {
private val prefs = NSUserDefaults.standardUserDefaults private val prefs = platform.Foundation.NSUserDefaults.standardUserDefaults
actual fun saveToken(token: String) { actual fun saveToken(token: String) {
val delegate = keychainDelegate val delegate = keychainDelegate
@@ -36,9 +34,8 @@ actual class TokenManager {
prefs.removeObjectForKey(TOKEN_KEY) prefs.removeObjectForKey(TOKEN_KEY)
prefs.synchronize() prefs.synchronize()
} else { } else {
// Fallback (should not happen in production) // Fail closed: never store auth tokens in insecure storage.
prefs.setObject(token, forKey = TOKEN_KEY) println("TokenManager: Keychain delegate not set, refusing to save token insecurely")
prefs.synchronize()
} }
} }
@@ -62,8 +59,9 @@ actual class TokenManager {
return null return null
} }
// Fallback to NSUserDefaults (should not happen in production) // Fail closed: no insecure fallback reads.
return prefs.stringForKey(TOKEN_KEY) println("TokenManager: Keychain delegate not set, refusing insecure token read")
return null
} }
actual fun clearToken() { actual fun clearToken() {

View File

@@ -768,6 +768,7 @@
1C685CD92EC5539000A9669B /* Debug */ = { 1C685CD92EC5539000A9669B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ARCHS = arm64;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -779,11 +780,13 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Casera;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera";
}; };
name = Debug; name = Debug;
@@ -791,6 +794,7 @@
1C685CDA2EC5539000A9669B /* Release */ = { 1C685CDA2EC5539000A9669B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ARCHS = arm64;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -802,11 +806,13 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.CaseraTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Casera;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Casera.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Casera";
}; };
name = Release; name = Release;

View File

@@ -19,12 +19,6 @@ class ContractorSharingManager: ObservableObject {
// MARK: - Private Properties // MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder() private let jsonDecoder = JSONDecoder()
private init() {} private init() {}
@@ -38,23 +32,17 @@ class ContractorSharingManager: ObservableObject {
// Get current username for export metadata // Get current username for export metadata
let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown" let currentUsername = DataManagerObservable.shared.currentUser?.username ?? "Unknown"
// Convert Contractor to SharedContractor using Kotlin extension let jsonContent = CaseraShareCodec.shared.encodeContractorPackage(
let sharedContractor = contractor.toSharedContractor(exportedBy: currentUsername) contractor: contractor,
exportedBy: currentUsername
)
// Create Swift-compatible structure for JSON encoding guard let jsonData = jsonContent.data(using: .utf8) else {
let exportData = SharedContractorExport(from: sharedContractor) print("ContractorSharingManager: Failed to encode contractor package as UTF-8")
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ContractorSharingManager: Failed to encode contractor to JSON")
return nil return nil
} }
// Create a safe filename let fileName = CaseraShareCodec.shared.safeShareFileName(displayName: contractor.name)
let safeName = contractor.name
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "/", with: "-")
.prefix(50)
let fileName = "\(safeName).casera"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do { do {

View File

@@ -12,6 +12,8 @@
<string>com.example.casera.pro.annual</string> <string>com.example.casera.pro.annual</string>
<key>CASERA_IAP_MONTHLY_PRODUCT_ID</key> <key>CASERA_IAP_MONTHLY_PRODUCT_ID</key>
<string>com.example.casera.pro.monthly</string> <string>com.example.casera.pro.monthly</string>
<key>CASERA_GOOGLE_WEB_CLIENT_ID</key>
<string></string>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
<dict> <dict>

View File

@@ -1,5 +1,8 @@
import Foundation import Foundation
import AuthenticationServices import AuthenticationServices
import CryptoKit
import Security
import UIKit
import ComposeApp import ComposeApp
/// Handles Google OAuth flow using ASWebAuthenticationSession. /// Handles Google OAuth flow using ASWebAuthenticationSession.
@@ -15,14 +18,18 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
var onSignInSuccess: ((Bool) -> Void)? var onSignInSuccess: ((Bool) -> Void)?
private var webAuthSession: ASWebAuthenticationSession? private var webAuthSession: ASWebAuthenticationSession?
private var codeVerifier: String?
private var oauthState: String?
private let redirectScheme = "casera"
private var redirectURI: String { "\(redirectScheme):/oauth2callback" }
// MARK: - Public // MARK: - Public
func signIn() { func signIn() {
guard !isLoading else { return } guard !isLoading else { return }
let clientId = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID guard let clientId = resolvedClientID() else {
guard ApiConfig.shared.isGoogleSignInConfigured else {
errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required." errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required."
return return
} }
@@ -30,9 +37,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
isLoading = true isLoading = true
errorMessage = nil 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 // 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")! var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
components.queryItems = [ components.queryItems = [
URLQueryItem(name: "client_id", value: clientId), 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: "scope", value: "openid email profile"),
URLQueryItem(name: "access_type", value: "offline"), URLQueryItem(name: "access_type", value: "offline"),
URLQueryItem(name: "prompt", value: "select_account"), 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 { guard let authURL = components.url else {
@@ -57,6 +72,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
guard let self else { return } guard let self else { return }
if let error { if let error {
self.resetOAuthState()
self.isLoading = false self.isLoading = false
// Don't show error for user cancellation // Don't show error for user cancellation
if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue { if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue {
@@ -66,14 +82,42 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
} }
guard let callbackURL, guard let callbackURL,
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
self.isLoading = false self.isLoading = false
self.resetOAuthState()
self.errorMessage = "Failed to get authorization code from Google" self.errorMessage = "Failed to get authorization code from Google"
return 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 // MARK: - ASWebAuthenticationPresentationContextProviding
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
// Return the key window for presentation MainActor.assumeIsolated {
let scenes = UIApplication.shared.connectedScenes // Return the key window for presentation
let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene let scenes = UIApplication.shared.connectedScenes
return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor() let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
}
} }
// MARK: - Private // MARK: - Private
/// Exchange authorization code for ID token via Google's token endpoint /// 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")! let tokenURL = URL(string: "https://oauth2.googleapis.com/token")!
var request = URLRequest(url: tokenURL) var request = URLRequest(url: tokenURL)
request.httpMethod = "POST" request.httpMethod = "POST"
@@ -106,16 +157,18 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
"client_id": clientId, "client_id": clientId,
"redirect_uri": redirectURI, "redirect_uri": redirectURI,
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code_verifier": codeVerifier,
] ]
request.httpBody = body
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } var encoded = URLComponents()
.joined(separator: "&") encoded.queryItems = body.map { URLQueryItem(name: $0.key, value: $0.value) }
.data(using: .utf8) request.httpBody = encoded.percentEncodedQuery?.data(using: .utf8)
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = "Failed to exchange authorization code" errorMessage = "Failed to exchange authorization code"
return return
@@ -123,6 +176,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let idToken = json["id_token"] as? String else { let idToken = json["id_token"] as? String else {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = "Failed to get ID token from Google" errorMessage = "Failed to get ID token from Google"
return return
@@ -131,6 +185,7 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
// Send ID token to backend // Send ID token to backend
await sendToBackend(idToken: idToken) await sendToBackend(idToken: idToken)
} catch { } catch {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = "Network error: \(error.localizedDescription)" errorMessage = "Network error: \(error.localizedDescription)"
} }
@@ -142,12 +197,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
let result = try? await APILayer.shared.googleSignIn(request: request) let result = try? await APILayer.shared.googleSignIn(request: request)
guard let result else { guard let result else {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = "Sign in failed. Please try again." errorMessage = "Sign in failed. Please try again."
return return
} }
if let success = result as? ApiResultSuccess<GoogleSignInResponse>, let response = success.data { if let success = result as? ApiResultSuccess<GoogleSignInResponse>, let response = success.data {
resetOAuthState()
isLoading = false isLoading = false
// Share token and API URL with widget extension // Share token and API URL with widget extension
@@ -161,11 +218,46 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication
onSignInSuccess?(response.user.verified) onSignInSuccess?(response.user.verified)
} else if let error = ApiResultBridge.error(from: result) { } else if let error = ApiResultBridge.error(from: result) {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = ErrorMessageParser.parse(error.message) errorMessage = ErrorMessageParser.parse(error.message)
} else { } else {
resetOAuthState()
isLoading = false isLoading = false
errorMessage = "Sign in failed. Please try again." 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: "")
}
} }

View File

@@ -29,12 +29,6 @@ class ResidenceSharingManager: ObservableObject {
// MARK: - Private Properties // MARK: - Private Properties
private let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return encoder
}()
private let jsonDecoder = JSONDecoder() private let jsonDecoder = JSONDecoder()
private init() {} private init() {}
@@ -70,21 +64,14 @@ class ResidenceSharingManager: ObservableObject {
return nil return nil
} }
// Create Swift-compatible structure for JSON encoding let jsonContent = CaseraShareCodec.shared.encodeSharedResidence(sharedResidence: sharedResidence)
let exportData = SharedResidenceExport(from: sharedResidence) guard let jsonData = jsonContent.data(using: .utf8) else {
print("ResidenceSharingManager: Failed to encode residence package as UTF-8")
guard let jsonData = try? jsonEncoder.encode(exportData) else {
print("ResidenceSharingManager: Failed to encode residence to JSON")
errorMessage = "Failed to create share file" errorMessage = "Failed to create share file"
return nil return nil
} }
// Create a safe filename let fileName = CaseraShareCodec.shared.safeShareFileName(displayName: residence.name)
let safeName = residence.name
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "/", with: "-")
.prefix(50)
let fileName = "\(safeName).casera"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do { do {