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