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 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)

View File

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

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,
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) }
}

View File

@@ -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
)
}

View File

@@ -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<ResidenceSharingState, (Residence) -> 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
)
}

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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 {