diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c3ff79f4..aa411a38 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -154,6 +154,7 @@ kotlin { implementation(libs.androidx.media3.ui) implementation(libs.material) + implementation(libs.coil.network.okhttp) } } diff --git a/composeApp/src/androidMain/kotlin/de/kitshn/AndroidApp.kt b/composeApp/src/androidMain/kotlin/de/kitshn/AndroidApp.kt index 5b7ffbdd..dc2e9aab 100644 --- a/composeApp/src/androidMain/kotlin/de/kitshn/AndroidApp.kt +++ b/composeApp/src/androidMain/kotlin/de/kitshn/AndroidApp.kt @@ -8,7 +8,12 @@ class AndroidApp : Application() { override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) + INSTANCE = this initKitshnAcra() } + companion object { + lateinit var INSTANCE: AndroidApp + } + } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.android.kt b/composeApp/src/androidMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.android.kt new file mode 100644 index 00000000..a87bc2b2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.android.kt @@ -0,0 +1,72 @@ +package de.kitshn.api.tandoor + +import android.security.KeyChain +import de.kitshn.AndroidApp +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.X509KeyManager +import java.net.Socket +import java.security.Principal + +actual fun createTandoorHttpClient( + credentials: TandoorCredentials, + onCertificateRequested: () -> Unit +): HttpClient { + return HttpClient(OkHttp) { + followRedirects = true + + engine { + config { + sslSocketFactory( + SSLContext.getInstance("TLS").apply { + init( + arrayOf( + object : X509KeyManager { + override fun getClientAliases(keyType: String?, issuers: Array?): Array? = null + + override fun chooseClientAlias(keyType: Array?, issuers: Array?, socket: Socket?): String? { + onCertificateRequested() + return credentials.clientCertificateAlias + } + + override fun getServerAliases(keyType: String?, issuers: Array?): Array? = null + + override fun chooseServerAlias(keyType: String?, issuers: Array?, socket: Socket?): String? = null + + override fun getCertificateChain(alias: String?): Array? { + return if (alias == credentials.clientCertificateAlias && alias != null) { + KeyChain.getCertificateChain(AndroidApp.INSTANCE, alias) + } else null + } + + override fun getPrivateKey(alias: String?): PrivateKey? { + return if (alias == credentials.clientCertificateAlias && alias != null) { + KeyChain.getPrivateKey(AndroidApp.INSTANCE, alias) + } else null + } + } + ), + null, + null + ) + }.socketFactory, + // We need a trust manager, but using the default system one is tricky to extract easily without custom setup. + // However, OkHttp uses the system default if we don't provide one, but we are setting the SSLSocketFactory. + // Let's try to get the default TrustManager. + getDefaultX509TrustManager() + ) + } + } + } +} + +private fun getDefaultX509TrustManager(): javax.net.ssl.X509TrustManager { + val factory = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.first { it is javax.net.ssl.X509TrustManager } as javax.net.ssl.X509TrustManager +} diff --git a/composeApp/src/androidMain/kotlin/de/kitshn/utils/ClientCertificateUtils.android.kt b/composeApp/src/androidMain/kotlin/de/kitshn/utils/ClientCertificateUtils.android.kt new file mode 100644 index 00000000..0e6c298d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/de/kitshn/utils/ClientCertificateUtils.android.kt @@ -0,0 +1,34 @@ +package de.kitshn.utils + +import android.app.Activity +import android.security.KeyChain +import android.security.KeyChainAliasCallback +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import de.kitshn.AppActivity + +actual class ClientCertificateSelector(private val activity: Activity) { + actual fun selectClientCertificate(callback: (alias: String?) -> Unit) { + KeyChain.choosePrivateKeyAlias( + activity, + object : KeyChainAliasCallback { + override fun alias(alias: String?) { + callback(alias) + } + }, + null, null, null, -1, null + ) + } +} + +@Composable +actual fun rememberClientCertificateSelector(): ClientCertificateSelector { + val context = LocalContext.current + return remember(context) { + // Assuming the context is an Activity or can be cast to one, which is true for Compose on Android usually. + // It might be a wrapper (ContextWrapper), so we might need to unwrap, but casting usually works for direct Activity. + // Keep it simple for now. + ClientCertificateSelector(context as Activity) + } +} diff --git a/composeApp/src/androidMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.android.kt b/composeApp/src/androidMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.android.kt new file mode 100644 index 00000000..92a11e21 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.android.kt @@ -0,0 +1,73 @@ +package de.kitshn.utils + +import android.security.KeyChain +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import de.kitshn.AndroidApp +import de.kitshn.api.tandoor.TandoorCredentials +import okhttp3.OkHttpClient +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.net.Socket +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509KeyManager +import javax.net.ssl.X509TrustManager + +actual fun createImageLoader( + context: PlatformContext, + credentials: TandoorCredentials +): ImageLoader { + val okHttpClient = if (credentials.clientCertificateAlias != null) { + val sslContext = SSLContext.getInstance("TLS").apply { + init( + arrayOf( + object : X509KeyManager { + override fun getClientAliases(keyType: String?, issuers: Array?): Array? = null + + override fun chooseClientAlias(keyType: Array?, issuers: Array?, socket: Socket?): String? { + return credentials.clientCertificateAlias + } + + override fun getServerAliases(keyType: String?, issuers: Array?): Array? = null + + override fun chooseServerAlias(keyType: String?, issuers: Array?, socket: Socket?): String? = null + + override fun getCertificateChain(alias: String?): Array? { + return if (alias == credentials.clientCertificateAlias && alias != null) { + KeyChain.getCertificateChain(AndroidApp.INSTANCE, alias) + } else null + } + + override fun getPrivateKey(alias: String?): PrivateKey? { + return if (alias == credentials.clientCertificateAlias && alias != null) { + KeyChain.getPrivateKey(AndroidApp.INSTANCE, alias) + } else null + } + } + ), + null, + null + ) + } + + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManager = trustManagerFactory.trustManagers.first { it is X509TrustManager } as X509TrustManager + + OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + } else { + OkHttpClient.Builder().build() + } + + return ImageLoader.Builder(context) + .components { + add(OkHttpNetworkFetcherFactory(okHttpClient)) + } + .build() +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/App.kt b/composeApp/src/commonMain/kotlin/de/kitshn/App.kt index c52385ab..b807ef84 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/App.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/App.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -25,12 +26,14 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import coil3.compose.LocalPlatformContext +import coil3.compose.setSingletonImageLoaderFactory import de.kitshn.api.tandoor.TandoorClient import de.kitshn.api.tandoor.TandoorCredentials import de.kitshn.cache.ShoppingListEntriesCache import de.kitshn.ui.route.navigation.PrimaryNavigation import de.kitshn.ui.theme.KitshnTheme import de.kitshn.ui.theme.custom.AvailableColorSchemes +import de.kitshn.utils.createImageLoader import kotlinx.coroutines.delay private val SavedTandoorClient = mutableStateOf(null) @@ -62,6 +65,15 @@ internal fun App( val density = LocalDensity.current + val credentials = vm.tandoorClient?.credentials + setSingletonImageLoaderFactory { context -> + if (credentials != null) { + createImageLoader(context, credentials) + } else { + coil3.ImageLoader.Builder(context).build() + } + } + DisposableEffect(key1 = Unit) { onDispose { SavedTandoorClient.value = vm.tandoorClient diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt b/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt index 3900684e..29495196 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt @@ -176,12 +176,7 @@ class KitshnViewModel( try { val response = tandoorClient!!.reqAny( endpoint = "/", - _method = HttpMethod.Get, - customHttpClient = HttpClient { - install(HttpTimeout) { - requestTimeoutMillis = 2000 - } - } + _method = HttpMethod.Get ) if(response.status == HttpStatusCode.OK) @@ -196,28 +191,23 @@ class KitshnViewModel( try { val response = tandoorClient!!.reqAny( endpoint = "/", - _method = HttpMethod.Get, - customHttpClient = HttpClient { - install(HttpTimeout) { - requestTimeoutMillis = 5000 - } - } + _method = HttpMethod.Get ) if(response.status == HttpStatusCode.OK) isOffline = false } catch(_: TandoorRequestsError) { } catch(_: SerializationException) { - } + } - if(isOffline) { - uiState.offlineState.isOffline = true + if(isOffline) { + uiState.offlineState.isOffline = true - // automatically switch to shopping page if offline - if((Clock.System.now().toEpochMilliseconds() - initTime) < 8000) { - if(navHostController?.currentDestination?.route != "main") return@launch - if(mainSubNavHostController?.currentDestination?.route != "home") return@launch - mainSubNavHostController?.navigate("shopping") + // automatically switch to shopping page if offline + if((Clock.System.now().toEpochMilliseconds() - initTime) < 8000) { + if(navHostController?.currentDestination?.route != "main") return@launch + if(mainSubNavHostController?.currentDestination?.route != "home") return@launch + mainSubNavHostController?.navigate("shopping") } } else { uiState.offlineState.isOffline = false diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt index 933927a8..618b9fcc 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/api/tandoor/TandoorClient.kt @@ -48,20 +48,28 @@ data class TandoorCredentials( val password: String = "", var token: TandoorCredentialsToken? = null, val cookie: String? = null, - val customHeaders: List = listOf() + val customHeaders: List = listOf(), + var clientCertificateAlias: String? = null ) +expect fun createTandoorHttpClient( + credentials: TandoorCredentials, + onCertificateRequested: () -> Unit +): HttpClient + class TandoorClient( val credentials: TandoorCredentials ) { - val httpClient = HttpClient { - followRedirects = true - } + var certificateRequested: Boolean = false - val longHttpClient = HttpClient { - followRedirects = true + val httpClient = createTandoorHttpClient(credentials) { + certificateRequested = true + } + val longHttpClient = createTandoorHttpClient(credentials) { + certificateRequested = true + }.config { install(HttpTimeout) { connectTimeoutMillis = 60000 requestTimeoutMillis = 60000 diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/KitshnFormImageUploadItem.kt b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/KitshnFormImageUploadItem.kt index a9e625aa..b26cf906 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/KitshnFormImageUploadItem.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/model/form/item/KitshnFormImageUploadItem.kt @@ -58,9 +58,6 @@ class KitshnFormImageUploadItem( override fun Render( modifier: Modifier ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - var imageLoadingState by remember { mutableStateOf( AsyncImagePainter.State.Loading(null) @@ -92,7 +89,6 @@ class KitshnFormImageUploadItem( }, contentDescription = label(), contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .fillMaxSize() .loadingPlaceHolder(imageLoadingState.translateState()) @@ -105,7 +101,6 @@ class KitshnFormImageUploadItem( }, contentDescription = label(), contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .fillMaxSize() .loadingPlaceHolder(imageLoadingState.translateState()) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/recipe/RecipeSearchField.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/recipe/RecipeSearchField.kt index f32176cc..522d587f 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/recipe/RecipeSearchField.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/input/recipe/RecipeSearchField.kt @@ -31,9 +31,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import coil3.ImageLoader import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext import de.kitshn.api.tandoor.TandoorClient import de.kitshn.api.tandoor.TandoorRequestState import de.kitshn.api.tandoor.model.recipe.TandoorRecipeOverview @@ -59,8 +57,6 @@ fun BaseRecipeSearchField( onValueChange: (value: String) -> Unit ) -> Unit ) { - val context = LocalPlatformContext.current - var selectedRecipe by remember { mutableStateOf(null) } LaunchedEffect(selectedRecipe) { onValueChange(selectedRecipe?.id) } @@ -106,7 +102,6 @@ fun BaseRecipeSearchField( model = selectedRecipe?.loadThumbnail(), contentDescription = stringResource(Res.string.common_title_image), contentScale = ContentScale.Crop, - imageLoader = ImageLoader(context), modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(8.dp)) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCard.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCard.kt index 86fa3a4d..991ee43a 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCard.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCard.kt @@ -37,9 +37,6 @@ fun HorizontalRecipeCard( onClick: (recipeOverview: TandoorRecipeOverview) -> Unit, onLongClick: (recipeOverview: TandoorRecipeOverview) -> Unit ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - Card( modifier = modifier, onClick = { onClick(recipeOverview) } @@ -60,7 +57,6 @@ fun HorizontalRecipeCard( model = recipeOverview.loadThumbnail(), contentDescription = recipeOverview.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(16.dp)) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCardLink.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCardLink.kt index 12c8ec99..afc79de0 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCardLink.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/HorizontalRecipeCardLink.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -18,9 +17,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.ImageLoader import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext import de.kitshn.api.tandoor.model.recipe.TandoorRecipeOverview import de.kitshn.ui.selectionMode.SelectionModeState import de.kitshn.ui.selectionMode.values.selectionModeListItemColors @@ -35,9 +32,6 @@ fun HorizontalRecipeCardLink( defaultColors: ListItemColors = ListItemDefaults.colors(), onClick: (recipeOverview: TandoorRecipeOverview) -> Unit ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val hapticFeedback = LocalHapticFeedback.current val colors = ListItemDefaults.selectionModeListItemColors( @@ -75,7 +69,6 @@ fun HorizontalRecipeCardLink( model = recipeOverview.loadThumbnail(), contentDescription = recipeOverview.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/RecipeCard.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/RecipeCard.kt index 8f6ab781..d734eacf 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/RecipeCard.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/RecipeCard.kt @@ -66,9 +66,6 @@ fun RecipeCard( onClickKeyword: (keyword: TandoorKeywordOverview) -> Unit, onClick: (recipeOverview: TandoorRecipeOverview) -> Unit, ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val hapticFeedback = LocalHapticFeedback.current val hazeState = remember { HazeState() } @@ -124,7 +121,6 @@ fun RecipeCard( }, contentDescription = recipeOverview?.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .fillMaxWidth() .run { diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/step/RecipeStepMultimediaBox.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/step/RecipeStepMultimediaBox.kt index ca78d0d5..33bbb747 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/step/RecipeStepMultimediaBox.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipe/step/RecipeStepMultimediaBox.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import coil3.ImageLoader import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter -import coil3.compose.LocalPlatformContext import de.kitshn.api.tandoor.model.TandoorStep import de.kitshn.api.tandoor.model.recipe.TandoorRecipe import de.kitshn.ui.dialog.AdaptiveFullscreenDialog @@ -54,9 +52,6 @@ fun RecipeStepMultimediaBox( additionalContent: @Composable () -> Unit = { }, placeholder: @Composable () -> Unit = { } ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val isVideo = (step.file?.name ?: "").contains("video") if(isVideo && !isVideoSupported()) { @@ -98,7 +93,6 @@ fun RecipeStepMultimediaBox( }, contentDescription = step.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .padding(contentPadding) .fillMaxWidth() @@ -159,9 +153,6 @@ private fun ImageDialog( onDismiss: () -> Unit, step: TandoorStep ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - var imageLoadingState by remember { mutableStateOf( AsyncImagePainter.State.Loading( @@ -196,7 +187,6 @@ private fun ImageDialog( }, contentDescription = step.name, contentScale = ContentScale.Fit, - imageLoader = imageLoader, modifier = Modifier .loadingPlaceHolder( loadingState = imageLoadingState.translateState(), diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipebook/HorizontalRecipeBookCard.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipebook/HorizontalRecipeBookCard.kt index f01478bc..c18f882c 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipebook/HorizontalRecipeBookCard.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/model/recipebook/HorizontalRecipeBookCard.kt @@ -69,9 +69,6 @@ fun HorizontalRecipeBookCard( onClick: (recipeBook: TandoorRecipeBook) -> Unit = {} ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val hapticFeedback = LocalHapticFeedback.current val colors = CardDefaults.selectionModeCardColors( @@ -168,7 +165,6 @@ fun HorizontalRecipeBookCard( model = recipeBook?.loadThumbnail(), contentDescription = recipeBook?.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .height(42.dp) .width(42.dp) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/recipe/import/RecipeImportCommon.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/recipe/import/RecipeImportCommon.kt index c1f6b59e..ab621549 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/recipe/import/RecipeImportCommon.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/dialog/recipe/import/RecipeImportCommon.kt @@ -37,9 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.ImageLoader import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext import de.kitshn.api.tandoor.TandoorRequestState import de.kitshn.api.tandoor.TandoorRequestStateState import de.kitshn.api.tandoor.model.recipe.TandoorRecipe @@ -143,9 +141,6 @@ fun LazyListScope.RecipeImportCommon( } item { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - var showChoosePhotoDialog by remember { mutableStateOf(false) } HorizontalMultiBrowseCarousel( @@ -172,8 +167,7 @@ fun LazyListScope.RecipeImportCommon( }, model = data.uploadImage, contentDescription = "", - contentScale = ContentScale.Crop, - imageLoader = imageLoader + contentScale = ContentScale.Crop ) Box( @@ -229,8 +223,7 @@ fun LazyListScope.RecipeImportCommon( }, model = url, contentDescription = url, - contentScale = ContentScale.Crop, - imageLoader = imageLoader + contentScale = ContentScale.Crop ) if(data.selectedImageUrl == url) Box( diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/onboarding/OnboardingSignIn.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/onboarding/OnboardingSignIn.kt index 07106dd1..67278459 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/onboarding/OnboardingSignIn.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/onboarding/OnboardingSignIn.kt @@ -24,9 +24,11 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.HighlightOff import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Password import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.VerifiedUser import androidx.compose.material.icons.rounded.Web import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -125,6 +127,7 @@ fun RouteOnboardingSignIn( val crashReportHandler = crashReportHandler() val coroutineScope = rememberCoroutineScope() + val certificateSelector = de.kitshn.utils.rememberClientCertificateSelector() var instanceUrlValue by rememberSaveable { mutableStateOf("https://app.tandoor.dev") } var instanceUrlDisplayValue by rememberSaveable { mutableStateOf("https://app.tandoor.dev") } @@ -134,15 +137,17 @@ fun RouteOnboardingSignIn( var showCustomHeadersDialog by remember { mutableStateOf(false) } val customHeaders = remember { mutableStateListOf() } + var clientCertificateAlias by rememberSaveable { mutableStateOf(null) } - LaunchedEffect(instanceUrlValue, showCustomHeadersDialog) { + LaunchedEffect(instanceUrlValue, showCustomHeadersDialog, clientCertificateAlias) { instanceUrlState = ErrorLoadingSuccessState.LOADING delay(1000) val client = TandoorClient( TandoorCredentials( instanceUrl = instanceUrlValue, - customHeaders = customHeaders + customHeaders = customHeaders, + clientCertificateAlias = clientCertificateAlias ) ) @@ -153,16 +158,39 @@ fun RouteOnboardingSignIn( hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) } catch(e: Exception) { - instanceUrlV1Error = true + if (client.certificateRequested && clientCertificateAlias == null) { + certificateSelector.selectClientCertificate { alias -> + if (alias != null) { + clientCertificateAlias = alias + } else { + instanceUrlV1Error = true + instanceUrlState = ErrorLoadingSuccessState.ERROR + hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject) + } + } + } else { + instanceUrlV1Error = true + instanceUrlState = ErrorLoadingSuccessState.ERROR + hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject) + } + } + } else { + if (client.certificateRequested && clientCertificateAlias == null) { + certificateSelector.selectClientCertificate { alias -> + if (alias != null) { + clientCertificateAlias = alias + } else { + instanceUrlV1Error = false + instanceUrlState = ErrorLoadingSuccessState.ERROR + hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject) + } + } + } else { + instanceUrlV1Error = false instanceUrlState = ErrorLoadingSuccessState.ERROR hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject) } - } else { - instanceUrlV1Error = false - instanceUrlState = ErrorLoadingSuccessState.ERROR - - hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject) } } @@ -188,7 +216,8 @@ fun RouteOnboardingSignIn( scope = "undefined", expires = "undefined" ), - customHeaders = customHeaders + customHeaders = customHeaders, + clientCertificateAlias = clientCertificateAlias ) p.vm.tandoorClient = TandoorClient( @@ -214,7 +243,8 @@ fun RouteOnboardingSignIn( instanceUrl = instanceUrlValue, username = usernameValue, password = passwordValue, - customHeaders = customHeaders + customHeaders = customHeaders, + clientCertificateAlias = clientCertificateAlias ) val client = TandoorClient(credentials) @@ -378,6 +408,29 @@ fun RouteOnboardingSignIn( } } + if (clientCertificateAlias != null) { + Spacer(Modifier.height(8.dp)) + androidx.compose.material3.InputChip( + selected = true, + onClick = { clientCertificateAlias = null }, + label = { Text("Client Certificate: $clientCertificateAlias") }, + trailingIcon = { + Icon( + Icons.Rounded.HighlightOff, + "Clear Certificate", + modifier = Modifier.size(18.dp) + ) + }, + leadingIcon = { + Icon( + Icons.Rounded.VerifiedUser, + "Certificate", + modifier = Modifier.size(18.dp) + ) + } + ) + } + Spacer(Modifier.height(12.dp)) HorizontalDivider() diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/recipe/cook/page/RecipeDone.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/recipe/cook/page/RecipeDone.kt index 9292a809..ae899db9 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/recipe/cook/page/RecipeDone.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/recipe/cook/page/RecipeDone.kt @@ -78,9 +78,6 @@ fun RouteRecipeCookPageDone( recipe: TandoorRecipe, servings: Int ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val coroutineScope = rememberCoroutineScope() val requestCookLogCreateState = rememberTandoorRequestState() @@ -135,7 +132,6 @@ fun RouteRecipeCookPageDone( model = recipe.loadThumbnail(), contentDescription = recipe.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .fillMaxWidth() .height(180.dp) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt index 491c5e9c..b2bd6e2e 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/recipe/details/RecipeDetails.kt @@ -215,9 +215,6 @@ fun ViewRecipeDetails( overrideServings: Double? = null ) { - val context = LocalPlatformContext.current - val imageLoader = remember { ImageLoader(context) } - val websiteHandler = launchWebsiteHandler() val shareContentHandler = shareContentHandler() @@ -654,7 +651,6 @@ fun ViewRecipeDetails( model = recipeOverview.loadThumbnail(), contentDescription = recipeOverview.name, contentScale = ContentScale.Crop, - imageLoader = imageLoader, modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/utils/ClientCertificateUtils.kt b/composeApp/src/commonMain/kotlin/de/kitshn/utils/ClientCertificateUtils.kt new file mode 100644 index 00000000..f8538b7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/utils/ClientCertificateUtils.kt @@ -0,0 +1,10 @@ +package de.kitshn.utils + +import androidx.compose.runtime.Composable + +expect class ClientCertificateSelector { + fun selectClientCertificate(callback: (alias: String?) -> Unit) +} + +@Composable +expect fun rememberClientCertificateSelector(): ClientCertificateSelector diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.kt b/composeApp/src/commonMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.kt new file mode 100644 index 00000000..29b76188 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.kt @@ -0,0 +1,10 @@ +package de.kitshn.utils + +import coil3.ImageLoader +import coil3.PlatformContext +import de.kitshn.api.tandoor.TandoorCredentials + +expect fun createImageLoader( + context: PlatformContext, + credentials: TandoorCredentials +): ImageLoader diff --git a/composeApp/src/iosMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.ios.kt b/composeApp/src/iosMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.ios.kt new file mode 100644 index 00000000..db24a355 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.ios.kt @@ -0,0 +1,15 @@ +package de.kitshn.api.tandoor + +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin + +actual fun createTandoorHttpClient( + credentials: TandoorCredentials, + onCertificateRequested: () -> Unit +): HttpClient { + return HttpClient(Darwin) { + followRedirects = true + + // TODO: Implement client certificate support for iOS + } +} diff --git a/composeApp/src/iosMain/kotlin/de/kitshn/utils/ClientCertificateUtils.ios.kt b/composeApp/src/iosMain/kotlin/de/kitshn/utils/ClientCertificateUtils.ios.kt new file mode 100644 index 00000000..2de147e8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/de/kitshn/utils/ClientCertificateUtils.ios.kt @@ -0,0 +1,16 @@ +package de.kitshn.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +actual class ClientCertificateSelector { + actual fun selectClientCertificate(callback: (alias: String?) -> Unit) { + // TODO: Implement client certificate selection for iOS + callback(null) + } +} + +@Composable +actual fun rememberClientCertificateSelector(): ClientCertificateSelector { + return remember { ClientCertificateSelector() } +} diff --git a/composeApp/src/iosMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.ios.kt b/composeApp/src/iosMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.ios.kt new file mode 100644 index 00000000..958ef096 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.ios.kt @@ -0,0 +1,13 @@ +package de.kitshn.utils + +import coil3.ImageLoader +import coil3.PlatformContext +import de.kitshn.api.tandoor.TandoorCredentials + +actual fun createImageLoader( + context: PlatformContext, + credentials: TandoorCredentials +): ImageLoader { + // iOS: Default ImageLoader (no client certificate support yet) + return ImageLoader.Builder(context).build() +} diff --git a/composeApp/src/jvmMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.jvm.kt b/composeApp/src/jvmMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.jvm.kt new file mode 100644 index 00000000..6ca23da9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/de/kitshn/api/tandoor/TandoorHttpClient.jvm.kt @@ -0,0 +1,13 @@ +package de.kitshn.api.tandoor + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp + +actual fun createTandoorHttpClient( + credentials: TandoorCredentials, + onCertificateRequested: () -> Unit +): HttpClient { + return HttpClient(OkHttp) { + followRedirects = true + } +} diff --git a/composeApp/src/jvmMain/kotlin/de/kitshn/utils/ClientCertificateUtils.jvm.kt b/composeApp/src/jvmMain/kotlin/de/kitshn/utils/ClientCertificateUtils.jvm.kt new file mode 100644 index 00000000..30a5bf3c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/de/kitshn/utils/ClientCertificateUtils.jvm.kt @@ -0,0 +1,15 @@ +package de.kitshn.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +actual class ClientCertificateSelector { + actual fun selectClientCertificate(callback: (alias: String?) -> Unit) { + callback(null) + } +} + +@Composable +actual fun rememberClientCertificateSelector(): ClientCertificateSelector { + return remember { ClientCertificateSelector() } +} diff --git a/composeApp/src/jvmMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.jvm.kt b/composeApp/src/jvmMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.jvm.kt new file mode 100644 index 00000000..7e4e278d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/de/kitshn/utils/CoilImageLoaderFactory.jvm.kt @@ -0,0 +1,13 @@ +package de.kitshn.utils + +import coil3.ImageLoader +import coil3.PlatformContext +import de.kitshn.api.tandoor.TandoorCredentials + +actual fun createImageLoader( + context: PlatformContext, + credentials: TandoorCredentials +): ImageLoader { + // JVM/Desktop: Default ImageLoader (no client certificate support yet) + return ImageLoader.Builder(context).build() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86f2a4a9..1d0ce0f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -88,6 +88,7 @@ material = { module = "com.google.android.material:material", version.ref = "mat kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } coil = { module = "io.coil-kt.coil3:coil-compose-core", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } compose-video = { module = "io.sanghun:compose-video", version.ref = "composeVideo" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }