Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ kotlin {
implementation(libs.androidx.media3.ui)

implementation(libs.material)
implementation(libs.coil.network.okhttp)
}
}

Expand Down
5 changes: 5 additions & 0 deletions composeApp/src/androidMain/kotlin/de/kitshn/AndroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ class AndroidApp : Application() {

override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
INSTANCE = this
initKitshnAcra()
}

companion object {
lateinit var INSTANCE: AndroidApp
}

}
Original file line number Diff line number Diff line change
@@ -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<out Principal>?): Array<String>? = null

override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
onCertificateRequested()
return credentials.clientCertificateAlias
}

override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null

override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null

override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<out Principal>?): Array<String>? = null

override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
return credentials.clientCertificateAlias
}

override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null

override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null

override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
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()
}
12 changes: 12 additions & 0 deletions composeApp/src/commonMain/kotlin/de/kitshn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<TandoorClient?>(null)
Expand Down Expand Up @@ -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
Expand Down
30 changes: 10 additions & 20 deletions composeApp/src/commonMain/kotlin/de/kitshn/KitshnViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,28 @@ data class TandoorCredentials(
val password: String = "",
var token: TandoorCredentialsToken? = null,
val cookie: String? = null,
val customHeaders: List<TandoorCredentialsCustomHeader> = listOf()
val customHeaders: List<TandoorCredentialsCustomHeader> = 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>(
AsyncImagePainter.State.Loading(null)
Expand Down Expand Up @@ -92,7 +89,6 @@ class KitshnFormImageUploadItem(
},
contentDescription = label(),
contentScale = ContentScale.Crop,
imageLoader = imageLoader,
modifier = Modifier
.fillMaxSize()
.loadingPlaceHolder(imageLoadingState.translateState())
Expand All @@ -105,7 +101,6 @@ class KitshnFormImageUploadItem(
},
contentDescription = label(),
contentScale = ContentScale.Crop,
imageLoader = imageLoader,
modifier = Modifier
.fillMaxSize()
.loadingPlaceHolder(imageLoadingState.translateState())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,8 +57,6 @@ fun BaseRecipeSearchField(
onValueChange: (value: String) -> Unit
) -> Unit
) {
val context = LocalPlatformContext.current

var selectedRecipe by remember { mutableStateOf<TandoorRecipeOverview?>(null) }
LaunchedEffect(selectedRecipe) { onValueChange(selectedRecipe?.id) }

Expand Down Expand Up @@ -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))
Expand Down
Loading