From ca583ae8dc0ac85b28e80af198f8eb3f92374637 Mon Sep 17 00:00:00 2001 From: stslex Date: Sun, 6 Jul 2025 00:56:50 +0300 Subject: [PATCH 1/2] core network client --- commonApp/build.gradle.kts | 2 + commonApp/src/androidMain/AndroidManifest.xml | 2 + .../kotlin/com/stslex/atten/di/AppModules.kt | 2 + .../stslex/atten/core/core/model/AppError.kt | 9 ++ .../atten/core/core/model/IgnoreError.kt | 2 + .../atten/core/database/db/AppDatabase.kt | 4 +- core/network/build.gradle.kts | 40 +++++++ .../stslex/atten/core/network/KtorLogger.kt | 14 +++ .../atten/core/network/api/AppHttpApi.kt | 8 ++ .../atten/core/network/api/AppHttpApiImpl.kt | 22 ++++ .../core/network/client/AppHttpClient.kt | 9 ++ .../core/network/client/AppHttpClientImpl.kt | 102 ++++++++++++++++++ .../core/network/di/ModuleCoreNetwork.kt | 10 ++ .../network/error_handler/ErrorHandler.kt | 5 + .../network/error_handler/ErrorHandlerImpl.kt | 57 ++++++++++ .../error_handler/RefreshTokenValidator.kt | 27 +++++ .../core/network/model/ErrorRepeatEnd.kt | 24 +++++ .../core/network/model/TokenResponseModel.kt | 16 +++ .../network/model/response/LoginOkResponse.kt | 16 +++ core/store/build.gradle.kts | 18 ++++ .../atten/core/store/AndroidSharedPrefs.kt | 19 ++++ .../core/store/user/UserSettings.android.kt | 16 +++ .../atten/core/store/di/ModuleCoreStore.kt | 19 ++++ .../atten/core/store/types/StoreString.kt | 38 +++++++ .../atten/core/store/user/UserSettings.kt | 14 +++ .../stslex/atten/core/store/user/UserStore.kt | 16 +++ .../atten/core/store/user/UserStoreImpl.kt | 34 ++++++ .../atten/core/store/user/UserSettings.ios.kt | 12 +++ .../data/model/ToDoDataMapper.kt | 8 +- .../di/ModuleFeatureHome.kt | 1 - gradle/libs.versions.toml | 34 +++++- settings.gradle.kts | 2 + 32 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/AppError.kt create mode 100644 core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/IgnoreError.kt create mode 100644 core/network/build.gradle.kts create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt create mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt create mode 100644 core/store/build.gradle.kts create mode 100644 core/store/src/androidMain/kotlin/com/stslex/atten/core/store/AndroidSharedPrefs.kt create mode 100644 core/store/src/androidMain/kotlin/com/stslex/atten/core/store/user/UserSettings.android.kt create mode 100644 core/store/src/commonMain/kotlin/com/stslex/atten/core/store/di/ModuleCoreStore.kt create mode 100644 core/store/src/commonMain/kotlin/com/stslex/atten/core/store/types/StoreString.kt create mode 100644 core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserSettings.kt create mode 100644 core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStore.kt create mode 100644 core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStoreImpl.kt create mode 100644 core/store/src/iosMain/kotlin/com/stslex/atten/core/store/user/UserSettings.ios.kt diff --git a/commonApp/build.gradle.kts b/commonApp/build.gradle.kts index cd36879..6d8f527 100644 --- a/commonApp/build.gradle.kts +++ b/commonApp/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { implementation(project(":core:paging")) implementation(project(":core:todo")) implementation(project(":core:auth")) + implementation(project(":core:network")) + implementation(project(":core:store")) implementation(project(":feature:home")) implementation(project(":feature:details")) diff --git a/commonApp/src/androidMain/AndroidManifest.xml b/commonApp/src/androidMain/AndroidManifest.xml index 4deb5b4..4024143 100644 --- a/commonApp/src/androidMain/AndroidManifest.xml +++ b/commonApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,8 @@ + + = listOf( ModuleCorePaging().module, ModuleCoreUiUtils().module, ModuleCoreAuth().module, + ModuleCoreNetwork().module, ModuleFeatureHome().module, ModuleFeatureDetails().module, ModuleFeatureSettings().module diff --git a/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/AppError.kt b/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/AppError.kt new file mode 100644 index 0000000..a270aea --- /dev/null +++ b/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/AppError.kt @@ -0,0 +1,9 @@ +package com.stslex.atten.core.core.model + +open class AppError( + message: String? = null, + cause: Throwable? = null +) : Throwable( + message = message, + cause = cause, +) \ No newline at end of file diff --git a/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/IgnoreError.kt b/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/IgnoreError.kt new file mode 100644 index 0000000..6b86cf3 --- /dev/null +++ b/core/core/src/commonMain/kotlin/com/stslex/atten/core/core/model/IgnoreError.kt @@ -0,0 +1,2 @@ +package com.stslex.atten.core.core.model + diff --git a/core/database/src/commonMain/kotlin/com/stslex/atten/core/database/db/AppDatabase.kt b/core/database/src/commonMain/kotlin/com/stslex/atten/core/database/db/AppDatabase.kt index 3c74016..b27c8d3 100644 --- a/core/database/src/commonMain/kotlin/com/stslex/atten/core/database/db/AppDatabase.kt +++ b/core/database/src/commonMain/kotlin/com/stslex/atten/core/database/db/AppDatabase.kt @@ -9,7 +9,8 @@ import androidx.sqlite.execSQL import com.stslex.atten.core.database.model.ToDoEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.datetime.Clock +import kotlin.time.Clock +import kotlin.time.ExperimentalTime @Database( entities = [ToDoEntity::class], @@ -25,6 +26,7 @@ abstract class AppDatabase : RoomDatabase() { } } +@OptIn(ExperimentalTime::class) fun getRoomDatabase( builder: RoomDatabase.Builder ): AppDatabase { diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts new file mode 100644 index 0000000..04bb98c --- /dev/null +++ b/core/network/build.gradle.kts @@ -0,0 +1,40 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.github.gmazzo.buildconfig.BuildConfigExtension +import java.util.Properties + +plugins { + alias(libs.plugins.convention.kmp.library) + alias(libs.plugins.buildConfig) +} + +kotlin { + sourceSets.apply { + commonMain { + dependencies { + implementation(project(":core:core")) + implementation(project(":core:store")) + implementation(libs.bundles.ktor) + } + } + buildConfig { + setLocalProperty(project.rootProject) + } + } +} + +fun BuildConfigExtension.setLocalProperty(dir: Project) { + val localProperties = gradleLocalProperties(dir.projectDir, providers) + + buildStringField(localProperties, "SERVER_HOST") + buildStringField(localProperties, "SERVER_API_VERSION") + buildStringField(localProperties, "SERVER_PORT") + buildStringField(localProperties, "SERVER_API_KEY") +} + +fun BuildConfigExtension.buildStringField(localProperties: Properties, name: String) { + buildConfigField("String", name, localProperties.getString(name)) +} + +fun Properties.getString(key: String): String { + return getProperty(key) ?: throw IllegalStateException("$key should be initialised") +} diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt new file mode 100644 index 0000000..a133787 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt @@ -0,0 +1,14 @@ +package com.stslex.atten.core.network + +import com.stslex.atten.core.core.logger.Log +import io.ktor.client.plugins.logging.Logger + +internal object KtorLogger : Logger { + + private const val TAG = "KTOR_LOGGER" + private val logger = Log.tag(TAG) + + override fun log(message: String) { + logger.v(message) + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt new file mode 100644 index 0000000..f75338f --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt @@ -0,0 +1,8 @@ +package com.stslex.atten.core.network.api + +import io.ktor.client.HttpClient + +interface AppHttpApi { + + suspend fun request(block: suspend HttpClient.() -> T): T +} diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt new file mode 100644 index 0000000..fd4f9f6 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt @@ -0,0 +1,22 @@ +package com.stslex.atten.core.network.api + +import com.stslex.atten.core.core.coroutine.dispatcher.AppDispatcher +import com.stslex.atten.core.network.client.AppHttpClient +import io.ktor.client.HttpClient +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +class AppHttpApiImpl( + private val appDispatcher: AppDispatcher, + private val appHttpClient: AppHttpClient +) : AppHttpApi { + + override suspend fun request( + block: suspend HttpClient.() -> T + ): T = withContext(appDispatcher.io) { + block(appHttpClient.client) + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt new file mode 100644 index 0000000..84ebad6 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt @@ -0,0 +1,9 @@ +package com.stslex.atten.core.network.client + +import io.ktor.client.HttpClient + +interface AppHttpClient { + + val client: HttpClient +} + diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt new file mode 100644 index 0000000..71f90fd --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt @@ -0,0 +1,102 @@ +package com.stslex.atten.core.network.client + +import AtTen.core.network.BuildConfig +import com.stslex.atten.core.network.KtorLogger +import com.stslex.atten.core.network.error_handler.ErrorHandler +import com.stslex.atten.core.store.user.UserStore +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.cio.CIOEngineConfig +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.cache.HttpCache +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.headers +import io.ktor.http.ContentType +import io.ktor.http.URLProtocol +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +internal class AppHttpClientImpl( + errorHandler: ErrorHandler, + private val userStore: UserStore, +) : AppHttpClient { + + override val client = HttpClient(CIO) { + install(HttpCache) + expectSuccess = true + HttpResponseValidator { handleResponseExceptionWithRequest(errorHandler) } + setupDefaultRequest() + setupNegotiation() + setupLogging() + } + + private fun HttpClientConfig.setupNegotiation() { + install(ContentNegotiation) { + json( + Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + explicitNulls = false + } + ) + } + } + + private fun HttpClientConfig.setupLogging() { + install(Logging) { + // TODO dev type log + logger = KtorLogger + level = LogLevel.ALL + } + } + + private fun HttpClientConfig<*>.installAuth() { + install(Auth) { + bearer { + loadTokens { + BearerTokens( + accessToken = userStore.accessToken.value, + refreshToken = userStore.refreshToken.value + ) + } + } + } + } + + private fun HttpClientConfig<*>.setupDefaultRequest() { + defaultRequest { + url( + scheme = URLProtocol.HTTP.name, + host = BuildConfig.SERVER_HOST, + port = BuildConfig.SERVER_PORT.toInt(), + path = BuildConfig.SERVER_API_VERSION, + block = { + contentType(ContentType.Application.Json) + } + ) + headers { + append( + API_KEY_NAME, + BuildConfig.SERVER_API_KEY + ) + } + } + } + + companion object { + private const val API_KEY_NAME = "X-Api-Key" + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt new file mode 100644 index 0000000..e70ed5e --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt @@ -0,0 +1,10 @@ +package com.stslex.atten.core.network.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Singleton + +@Module +@ComponentScan("com.stslex.atten.core.network") +@Singleton +class ModuleCoreNetwork \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt new file mode 100644 index 0000000..ad50f5c --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt @@ -0,0 +1,5 @@ +package com.stslex.atten.core.network.error_handler + +import io.ktor.client.plugins.CallRequestExceptionHandler + +interface ErrorHandler : CallRequestExceptionHandler \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt new file mode 100644 index 0000000..a309ee3 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt @@ -0,0 +1,57 @@ +package com.stslex.atten.core.network.error_handler + +import com.stslex.atten.core.network.client.AppHttpClient +import com.stslex.atten.core.network.error_handler.RefreshTokenValidator.setupResponseValidator +import com.stslex.atten.core.network.model.NetworkError +import com.stslex.atten.core.network.model.TokenResponseModel +import com.stslex.atten.core.store.user.UserStore +import io.ktor.client.call.body +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.HttpRequest +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +internal class ErrorHandlerImpl( + private val appClient: Lazy, + private val userStore: UserStore, +) : ErrorHandler { + + private var refreshJob: Job? = null + + override suspend fun invoke(cause: Throwable, request: HttpRequest) { + when { + cause !is ResponseException -> throw cause + cause.response.status.value == HttpStatusCode.Unauthorized.value -> refreshToken() + else -> throw cause + } + } + + private suspend fun refreshToken() { + if (refreshJob?.isActive == true) return + refreshJob = coroutineScope { + launch { + val tokenResponse = appClient.value + .client + .setupResponseValidator() + .get("auth/refresh") { + bearerAuth(userStore.refreshToken.value) + } + .body() + userStore.uuid.value = tokenResponse.uuid + userStore.refreshToken.value = tokenResponse.refreshToken + userStore.accessToken.value = tokenResponse.refreshToken + userStore.email.value = tokenResponse.email + // TODO remove error throw after refresh token (move logic out from throwing) + throw NetworkError.ErrorRepeatEnd + } + } + } +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt new file mode 100644 index 0000000..11e11e5 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt @@ -0,0 +1,27 @@ +package com.stslex.atten.core.network.error_handler + +import com.stslex.atten.core.network.model.NetworkError.ErrorRefresh +import io.ktor.client.HttpClient +import io.ktor.client.plugins.CallRequestExceptionHandler +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.HttpRequest +import io.ktor.http.HttpStatusCode + +internal object RefreshTokenValidator : CallRequestExceptionHandler { + + fun HttpClient.setupResponseValidator(): HttpClient = config { + HttpResponseValidator { + handleResponseExceptionWithRequest(this@RefreshTokenValidator) + } + } + + override suspend fun invoke(cause: Throwable, request: HttpRequest) { + throw when { + cause !is ResponseException -> cause + cause.response.status.value == HttpStatusCode.Unauthorized.value -> ErrorRefresh(cause) + else -> cause + } + } +} + diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt new file mode 100644 index 0000000..9d4f5eb --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt @@ -0,0 +1,24 @@ +package com.stslex.atten.core.network.model + +import com.stslex.atten.core.core.model.AppError + + +sealed class NetworkError( + message: String, + cause: Throwable? = null +) : AppError( + message = message, + cause = cause, +) { + + /** + * Error repeat request. + * Show that the request was repeated after a refresh token + * @see com.stslex.atten.core.network.api.AppHttpApiImpl.request + */ + internal data object ErrorRepeatEnd : NetworkError("") + + data class ErrorRefresh( + override val cause: Throwable, + ) : NetworkError("success token not valid", cause) +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt new file mode 100644 index 0000000..ff4ae24 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TokenResponseModel( + @SerialName("uuid") + val uuid: String, + @SerialName("email") + val email: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt new file mode 100644 index 0000000..9b873c4 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.network.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginOkResponse( + @SerialName("uuid") + val uuid: String, + @SerialName("email") + val email: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("refresh_token") + val refreshToken: String +) \ No newline at end of file diff --git a/core/store/build.gradle.kts b/core/store/build.gradle.kts new file mode 100644 index 0000000..e47881a --- /dev/null +++ b/core/store/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.convention.kmp.library) +} + +kotlin { + sourceSets.apply { + commonMain { + dependencies { + implementation(project(":core:core")) + + implementation(libs.multiplatform.settings) + } + } + androidMain.dependencies { + implementation(libs.androidx.security.crypto) + } + } +} \ No newline at end of file diff --git a/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/AndroidSharedPrefs.kt b/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/AndroidSharedPrefs.kt new file mode 100644 index 0000000..5638dce --- /dev/null +++ b/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/AndroidSharedPrefs.kt @@ -0,0 +1,19 @@ +package com.stslex.atten.core.store + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +internal fun createEncriptedSharedPreferences(context: Context, name: String): SharedPreferences { + val masterKey: MasterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + context, + "${name.lowercase()}_shared_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} \ No newline at end of file diff --git a/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/user/UserSettings.android.kt b/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/user/UserSettings.android.kt new file mode 100644 index 0000000..96082dd --- /dev/null +++ b/core/store/src/androidMain/kotlin/com/stslex/atten/core/store/user/UserSettings.android.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.store.user + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import com.stslex.atten.core.store.createEncriptedSharedPreferences +import org.koin.android.ext.koin.androidContext +import org.koin.core.scope.Scope + +internal actual fun Scope.createUserSettings(): UserSettings { + val delegate = createEncriptedSharedPreferences( + context = androidContext(), + name = UserSettings.NAME + ) + val userSettings = SharedPreferencesSettings(delegate) + return object : UserSettings, Settings by userSettings {} +} \ No newline at end of file diff --git a/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/di/ModuleCoreStore.kt b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/di/ModuleCoreStore.kt new file mode 100644 index 0000000..f9a177f --- /dev/null +++ b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/di/ModuleCoreStore.kt @@ -0,0 +1,19 @@ +package com.stslex.atten.core.store.di + +import com.stslex.atten.core.store.user.UserSettings +import com.stslex.atten.core.store.user.createUserSettings +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton +import org.koin.core.scope.Scope + +@Module +@ComponentScan("com.stslex.atten.core.store") +@Singleton +class ModuleCoreStore { + + @Single + @Singleton + fun userSettings(scope: Scope): UserSettings = scope.createUserSettings() +} diff --git a/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/types/StoreString.kt b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/types/StoreString.kt new file mode 100644 index 0000000..06e5c8d --- /dev/null +++ b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/types/StoreString.kt @@ -0,0 +1,38 @@ +package com.stslex.atten.core.store.types + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class StoreString( + private val settings: Settings, + private val key: String, + defaultValue: String = EMPTY_VALUE +) { + + private val _flowValue = MutableStateFlow(settings.getString(key, defaultValue)) + val flowValue: StateFlow = _flowValue.asStateFlow() + + var value: String + get() = flowValue.value + set(value) { + settings[key] = value + _flowValue.value = value + } + + companion object { + + private const val EMPTY_VALUE = "" + + internal fun Settings.string( + key: String, + defaultValue: String = EMPTY_VALUE + ): StoreString = StoreString( + settings = this, + key = key, + defaultValue = defaultValue + ) + } +} \ No newline at end of file diff --git a/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserSettings.kt b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserSettings.kt new file mode 100644 index 0000000..92569a0 --- /dev/null +++ b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserSettings.kt @@ -0,0 +1,14 @@ +package com.stslex.atten.core.store.user + +import com.russhwolf.settings.Settings +import org.koin.core.scope.Scope + +interface UserSettings : Settings { + + companion object { + + const val NAME = "user.settings" + } +} + +internal expect fun Scope.createUserSettings(): UserSettings diff --git a/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStore.kt b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStore.kt new file mode 100644 index 0000000..2eaf39f --- /dev/null +++ b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStore.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.store.user + +import com.stslex.atten.core.store.types.StoreString + +interface UserStore { + + val accessToken: StoreString + + val refreshToken: StoreString + + val email: StoreString + + val uuid: StoreString + + fun clear() +} diff --git a/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStoreImpl.kt b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStoreImpl.kt new file mode 100644 index 0000000..9fc4705 --- /dev/null +++ b/core/store/src/commonMain/kotlin/com/stslex/atten/core/store/user/UserStoreImpl.kt @@ -0,0 +1,34 @@ +package com.stslex.atten.core.store.user + +import com.stslex.atten.core.store.types.StoreString +import com.stslex.atten.core.store.types.StoreString.Companion.string +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +class UserStoreImpl( + private val userSettings: UserSettings +) : UserStore { + + override val accessToken: StoreString = userSettings.string(KEY_TOKEN) + + override val refreshToken: StoreString = userSettings.string(KEY_REFRESH_TOKEN) + + override val email: StoreString = userSettings.string(KEY_EMAIL) + + override val uuid: StoreString = userSettings.string(KEY_UUID) + + override fun clear() { + userSettings.clear() + } + + companion object { + private const val KEY_TOKEN = "user.token" + private const val KEY_REFRESH_TOKEN = "user.refresh_token" + private const val KEY_EMAIL = "user.email" + private const val KEY_UUID = "user.uuid" + + private const val EMPTY_VALUE = "" + } +} \ No newline at end of file diff --git a/core/store/src/iosMain/kotlin/com/stslex/atten/core/store/user/UserSettings.ios.kt b/core/store/src/iosMain/kotlin/com/stslex/atten/core/store/user/UserSettings.ios.kt new file mode 100644 index 0000000..507c6df --- /dev/null +++ b/core/store/src/iosMain/kotlin/com/stslex/atten/core/store/user/UserSettings.ios.kt @@ -0,0 +1,12 @@ +package com.stslex.atten.core.store.user + +import com.russhwolf.settings.NSUserDefaultsSettings +import com.russhwolf.settings.Settings +import org.koin.core.scope.Scope +import platform.Foundation.NSUserDefaults + +internal actual fun Scope.createUserSettings(): UserSettings { + val delegate = NSUserDefaults(suiteName = UserSettings.NAME) + val settings: Settings = NSUserDefaultsSettings(delegate) + return object : UserSettings, Settings by settings {} +} \ No newline at end of file diff --git a/core/todo/src/commonMain/kotlin/com.stslex.atten.core.todo/data/model/ToDoDataMapper.kt b/core/todo/src/commonMain/kotlin/com.stslex.atten.core.todo/data/model/ToDoDataMapper.kt index 7455a40..508cc0d 100644 --- a/core/todo/src/commonMain/kotlin/com.stslex.atten.core.todo/data/model/ToDoDataMapper.kt +++ b/core/todo/src/commonMain/kotlin/com.stslex.atten.core.todo/data/model/ToDoDataMapper.kt @@ -1,12 +1,14 @@ package com.stslex.atten.core.todo.data.model import com.stslex.atten.core.database.model.ToDoEntity -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) fun ToDoEntity.toData() = ToDoDataModel( uuid = uuid, title = title, @@ -15,6 +17,7 @@ fun ToDoEntity.toData() = ToDoDataModel( updatedAt = Instant.fromEpochMilliseconds(createdAt).toLocalDateTime(TimeZone.UTC), ) +@OptIn(ExperimentalTime::class) fun UpdateTodoDataModel.toUpdatedEntity() = ToDoEntity( uuid = uuid, title = title, @@ -23,6 +26,7 @@ fun UpdateTodoDataModel.toUpdatedEntity() = ToDoEntity( updatedAt = Clock.System.now().toEpochMilliseconds() ) +@OptIn(ExperimentalTime::class) fun CreateTodoDataModel.toCreateEntity(): ToDoEntity = Clock.System.now() .toEpochMilliseconds() .let { currentDateTime -> diff --git a/feature/home/src/commonMain/kotlin/com.stslex.atten.feature.home/di/ModuleFeatureHome.kt b/feature/home/src/commonMain/kotlin/com.stslex.atten.feature.home/di/ModuleFeatureHome.kt index a0e466d..63ad842 100644 --- a/feature/home/src/commonMain/kotlin/com.stslex.atten.feature.home/di/ModuleFeatureHome.kt +++ b/feature/home/src/commonMain/kotlin/com.stslex.atten.feature.home/di/ModuleFeatureHome.kt @@ -3,7 +3,6 @@ package com.stslex.atten.feature.home.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module - @Module @ComponentScan("com.stslex.atten.feature.home") class ModuleFeatureHome diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02d01a0..c581dc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ lifecycle = "2.8.2" immutableCollection = "0.3.5" serialization = "1.7.1" navigation = "2.9.0-beta03" -datetime = "0.6.0" +datetime = "0.7.0" # decompose dependencies decompose = "3.3.0" @@ -37,6 +37,11 @@ parcelize = "0.2.4" gms = "21.3.0" +ktor = "3.2.1" +buildConfig = "5.6.7" +multiplatformSettings = "1.3.0" +securityCrypto = "1.1.0-beta01" + [libraries] android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -68,6 +73,17 @@ koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmo koin-ksp-compiler = { group = "io.insert-koin", name = "koin-ksp-compiler", version.ref = "koin-ksp" } koin-ksp-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koin-ksp" } +# ktor +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version = "1.7.9" } + koin-test = { group = "io.insert-koin", name = "koin-test" } koin-test-junit = { group = "io.insert-koin", name = "koin-test-junit4" } coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.8.0" } @@ -84,6 +100,8 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } + kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "datetime" } @@ -96,6 +114,8 @@ essenty-lifecycle = { module = "com.arkivanov.essenty:lifecycle", version.ref = essenty-stateKeeper = { module = "com.arkivanov.essenty:state-keeper", version.ref = "essenty" } essenty-backHandler = { module = "com.arkivanov.essenty:back-handler", version.ref = "essenty" } parcelize-darwin = { module = "com.arkivanov.parcelize.darwin:runtime", version.ref = "parcelize" } + +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } [bundles] test = [ "kotlin-test", @@ -105,7 +125,14 @@ test = [ "mockito", "junit" ] - +ktor = [ + "ktor-client-core", + "ktor-client-logging", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-cio", + "ktor-client-auth" +] [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -125,4 +152,5 @@ convention-kmp-feature = { id = "convention.kmp.feature" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" } -serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 34ef1e4..0c4be94 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,9 @@ include(":core:ui:mvi") include(":core:database") include(":core:paging") include(":core:todo") +include(":core:network") include(":core:auth") +include(":core:store") include(":feature:home") include(":feature:details") From 1420bbad5b2457f61c4fa4afff8a6892d3a57b44 Mon Sep 17 00:00:00 2001 From: stslex Date: Wed, 13 Aug 2025 23:25:13 +0300 Subject: [PATCH 2/2] core network api/client --- commonApp/build.gradle.kts | 3 +- .../kotlin/com/stslex/atten/di/AppModules.kt | 2 +- core/network/{ => api}/build.gradle.kts | 0 .../atten/core/network/api/AppHttpApi.kt | 0 .../atten/core/network/api}/AppHttpClient.kt | 5 +- .../atten/core/network/api/AuthApiClient.kt | 10 ++++ .../atten/core/network/api}/ErrorHandler.kt | 2 +- .../core/network/api/model/NetworkError.kt} | 10 +--- .../network/api}/model/TokenResponseModel.kt | 2 +- .../api/model/request/AuthGoogleRequest.kt | 10 ++++ .../api}/model/response/LoginOkResponse.kt | 2 +- core/network/client/build.gradle.kts | 41 +++++++++++++ .../network/client/client}/AppHttpApiImpl.kt | 5 +- .../client}/client/AppHttpClientImpl.kt | 14 +++-- .../client/client/AuthApiClientImpl.kt | 55 ++++++++++++++++++ .../network/client}/di/ModuleCoreNetwork.kt | 4 +- .../network/client/error/ErrorHandlerImpl.kt | 51 +++++++++++++++++ .../core/network/client/error}/KtorLogger.kt | 2 +- .../client/error}/RefreshTokenValidator.kt | 11 ++-- .../network/error_handler/ErrorHandlerImpl.kt | 57 ------------------- iosApp/iosApp.xcodeproj/project.pbxproj | 9 ++- settings.gradle.kts | 3 +- 22 files changed, 204 insertions(+), 94 deletions(-) rename core/network/{ => api}/build.gradle.kts (100%) rename core/network/{ => api}/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt (100%) rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network/client => api/src/commonMain/kotlin/com/stslex/atten/core/network/api}/AppHttpClient.kt (64%) create mode 100644 core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AuthApiClient.kt rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network/error_handler => api/src/commonMain/kotlin/com/stslex/atten/core/network/api}/ErrorHandler.kt (68%) rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt => api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt} (52%) rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network => api/src/commonMain/kotlin/com/stslex/atten/core/network/api}/model/TokenResponseModel.kt (87%) create mode 100644 core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/request/AuthGoogleRequest.kt rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network => api/src/commonMain/kotlin/com/stslex/atten/core/network/api}/model/response/LoginOkResponse.kt (85%) create mode 100644 core/network/client/build.gradle.kts rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network/api => client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client}/AppHttpApiImpl.kt (77%) rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network => client/src/commonMain/kotlin/com/stslex/atten/core/network/client}/client/AppHttpClientImpl.kt (88%) create mode 100644 core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AuthApiClientImpl.kt rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network => client/src/commonMain/kotlin/com/stslex/atten/core/network/client}/di/ModuleCoreNetwork.kt (62%) create mode 100644 core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/ErrorHandlerImpl.kt rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network => client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error}/KtorLogger.kt (84%) rename core/network/{src/commonMain/kotlin/com/stslex/atten/core/network/error_handler => client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error}/RefreshTokenValidator.kt (71%) delete mode 100644 core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt diff --git a/commonApp/build.gradle.kts b/commonApp/build.gradle.kts index 6d8f527..55d902e 100644 --- a/commonApp/build.gradle.kts +++ b/commonApp/build.gradle.kts @@ -14,7 +14,8 @@ kotlin { implementation(project(":core:paging")) implementation(project(":core:todo")) implementation(project(":core:auth")) - implementation(project(":core:network")) + implementation(project(":core:network:api")) + implementation(project(":core:network:client")) implementation(project(":core:store")) implementation(project(":feature:home")) diff --git a/commonApp/src/commonMain/kotlin/com/stslex/atten/di/AppModules.kt b/commonApp/src/commonMain/kotlin/com/stslex/atten/di/AppModules.kt index bc2233d..4798884 100644 --- a/commonApp/src/commonMain/kotlin/com/stslex/atten/di/AppModules.kt +++ b/commonApp/src/commonMain/kotlin/com/stslex/atten/di/AppModules.kt @@ -3,7 +3,7 @@ package com.stslex.atten.di import com.stslex.atten.core.auth.di.ModuleCoreAuth import com.stslex.atten.core.core.di.ModuleCore import com.stslex.atten.core.database.di.ModuleCoreDatabase -import com.stslex.atten.core.network.di.ModuleCoreNetwork +import com.stslex.atten.core.network.client.di.ModuleCoreNetwork import com.stslex.atten.core.paging.di.ModuleCorePaging import com.stslex.atten.core.todo.di.ModuleCoreToDo import com.stslex.atten.core.ui.kit.utils.ModuleCoreUiUtils diff --git a/core/network/build.gradle.kts b/core/network/api/build.gradle.kts similarity index 100% rename from core/network/build.gradle.kts rename to core/network/api/build.gradle.kts diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt similarity index 100% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApi.kt diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt similarity index 64% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt index 84ebad6..06c0337 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClient.kt +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt @@ -1,9 +1,8 @@ -package com.stslex.atten.core.network.client +package com.stslex.atten.core.network.api import io.ktor.client.HttpClient interface AppHttpClient { val client: HttpClient -} - +} \ No newline at end of file diff --git a/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AuthApiClient.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AuthApiClient.kt new file mode 100644 index 0000000..9ed83e0 --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AuthApiClient.kt @@ -0,0 +1,10 @@ +package com.stslex.atten.core.network.api + +import com.stslex.atten.core.network.api.model.TokenResponseModel + +interface AuthApiClient { + + suspend fun auth(token: String): TokenResponseModel + + suspend fun refresh(): TokenResponseModel +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt similarity index 68% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt index ad50f5c..8a60289 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandler.kt +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt @@ -1,4 +1,4 @@ -package com.stslex.atten.core.network.error_handler +package com.stslex.atten.core.network.api import io.ktor.client.plugins.CallRequestExceptionHandler diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt similarity index 52% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt index 9d4f5eb..2a17bfc 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/ErrorRepeatEnd.kt +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt @@ -1,8 +1,7 @@ -package com.stslex.atten.core.network.model +package com.stslex.atten.core.network.api.model import com.stslex.atten.core.core.model.AppError - sealed class NetworkError( message: String, cause: Throwable? = null @@ -11,13 +10,6 @@ sealed class NetworkError( cause = cause, ) { - /** - * Error repeat request. - * Show that the request was repeated after a refresh token - * @see com.stslex.atten.core.network.api.AppHttpApiImpl.request - */ - internal data object ErrorRepeatEnd : NetworkError("") - data class ErrorRefresh( override val cause: Throwable, ) : NetworkError("success token not valid", cause) diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt similarity index 87% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt index ff4ae24..b4e0348 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/TokenResponseModel.kt +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt @@ -1,4 +1,4 @@ -package com.stslex.atten.core.network.model +package com.stslex.atten.core.network.api.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/request/AuthGoogleRequest.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/request/AuthGoogleRequest.kt new file mode 100644 index 0000000..a279a9f --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/request/AuthGoogleRequest.kt @@ -0,0 +1,10 @@ +package com.stslex.atten.core.network.api.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthGoogleRequest( + @SerialName("id_token") + val idToken: String, +) \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt similarity index 85% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt rename to core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt index 9b873c4..5ea34fe 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/model/response/LoginOkResponse.kt +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt @@ -1,4 +1,4 @@ -package com.stslex.atten.core.network.model.response +package com.stslex.atten.core.network.api.model.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/core/network/client/build.gradle.kts b/core/network/client/build.gradle.kts new file mode 100644 index 0000000..4a2f659 --- /dev/null +++ b/core/network/client/build.gradle.kts @@ -0,0 +1,41 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import com.github.gmazzo.buildconfig.BuildConfigExtension +import java.util.Properties + +plugins { + alias(libs.plugins.convention.kmp.library) + alias(libs.plugins.buildConfig) +} + +kotlin { + sourceSets.apply { + commonMain { + dependencies { + implementation(project(":core:core")) + implementation(project(":core:network:api")) + implementation(project(":core:store")) + implementation(libs.bundles.ktor) + } + } + buildConfig { + setLocalProperty(project.rootProject) + } + } +} + +fun BuildConfigExtension.setLocalProperty(dir: Project) { + val localProperties = gradleLocalProperties(dir.projectDir, providers) + + buildStringField(localProperties, "SERVER_HOST") + buildStringField(localProperties, "SERVER_API_VERSION") + buildStringField(localProperties, "SERVER_PORT") + buildStringField(localProperties, "SERVER_API_KEY") +} + +fun BuildConfigExtension.buildStringField(localProperties: Properties, name: String) { + buildConfigField("String", name, localProperties.getString(name)) +} + +fun Properties.getString(key: String): String { + return getProperty(key) ?: throw IllegalStateException("$key should be initialised") +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt similarity index 77% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt rename to core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt index fd4f9f6..c76f41f 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpApiImpl.kt +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt @@ -1,7 +1,8 @@ -package com.stslex.atten.core.network.api +package com.stslex.atten.core.network.client.client import com.stslex.atten.core.core.coroutine.dispatcher.AppDispatcher -import com.stslex.atten.core.network.client.AppHttpClient +import com.stslex.atten.core.network.api.AppHttpApi +import com.stslex.atten.core.network.api.AppHttpClient import io.ktor.client.HttpClient import kotlinx.coroutines.withContext import org.koin.core.annotation.Single diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt similarity index 88% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt rename to core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt index 71f90fd..66ba288 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/client/AppHttpClientImpl.kt +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt @@ -1,8 +1,9 @@ -package com.stslex.atten.core.network.client +package com.stslex.atten.core.network.client.client -import AtTen.core.network.BuildConfig -import com.stslex.atten.core.network.KtorLogger -import com.stslex.atten.core.network.error_handler.ErrorHandler +import AtTen.core.network.client.BuildConfig +import com.stslex.atten.core.network.api.AppHttpClient +import com.stslex.atten.core.network.api.ErrorHandler +import com.stslex.atten.core.network.client.error.KtorLogger import com.stslex.atten.core.store.user.UserStore import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -34,12 +35,13 @@ internal class AppHttpClientImpl( ) : AppHttpClient { override val client = HttpClient(CIO) { - install(HttpCache) + install(HttpCache.Companion) expectSuccess = true HttpResponseValidator { handleResponseExceptionWithRequest(errorHandler) } setupDefaultRequest() setupNegotiation() setupLogging() + installAuth() } private fun HttpClientConfig.setupNegotiation() { @@ -79,7 +81,7 @@ internal class AppHttpClientImpl( private fun HttpClientConfig<*>.setupDefaultRequest() { defaultRequest { url( - scheme = URLProtocol.HTTP.name, + scheme = URLProtocol.Companion.HTTP.name, host = BuildConfig.SERVER_HOST, port = BuildConfig.SERVER_PORT.toInt(), path = BuildConfig.SERVER_API_VERSION, diff --git a/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AuthApiClientImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AuthApiClientImpl.kt new file mode 100644 index 0000000..25f073e --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AuthApiClientImpl.kt @@ -0,0 +1,55 @@ +package com.stslex.atten.core.network.client.client + +import com.stslex.atten.core.network.api.AppHttpApi +import com.stslex.atten.core.network.api.AuthApiClient +import com.stslex.atten.core.network.api.model.request.AuthGoogleRequest +import com.stslex.atten.core.network.client.error.RefreshTokenValidator.setupResponseValidator +import com.stslex.atten.core.network.api.model.TokenResponseModel +import com.stslex.atten.core.store.user.UserStore +import io.ktor.client.call.body +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +class AuthApiClientImpl( + private val appHttpApi: AppHttpApi, + private val userStore: UserStore, +) : AuthApiClient { + + override suspend fun auth(token: String): TokenResponseModel = appHttpApi.request { + post("$AUTH_HOST/$GOOGLE_AUTH_HOST") { + setBody(AuthGoogleRequest(token)) + } + .body() + .saveIntoUserStore() + } + + override suspend fun refresh(): TokenResponseModel = appHttpApi.request { + setupResponseValidator() + .get("$AUTH_HOST/$REFRESH_HOST") { + bearerAuth(userStore.refreshToken.value) + } + .body() + .saveIntoUserStore() + } + + private fun TokenResponseModel.saveIntoUserStore(): TokenResponseModel = apply { + userStore.uuid.value = uuid + userStore.refreshToken.value = refreshToken + userStore.accessToken.value = accessToken + userStore.email.value = email + } + + companion object { + + private const val AUTH_HOST = "auth" + private const val REFRESH_HOST = "refresh" + private const val GOOGLE_AUTH_HOST = "google" + } + +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt similarity index 62% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt rename to core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt index e70ed5e..2c3048e 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/di/ModuleCoreNetwork.kt +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt @@ -1,10 +1,10 @@ -package com.stslex.atten.core.network.di +package com.stslex.atten.core.network.client.di import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module import org.koin.core.annotation.Singleton @Module -@ComponentScan("com.stslex.atten.core.network") +@ComponentScan("com.stslex.atten.core.network.client") @Singleton class ModuleCoreNetwork \ No newline at end of file diff --git a/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/ErrorHandlerImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/ErrorHandlerImpl.kt new file mode 100644 index 0000000..57761d2 --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/ErrorHandlerImpl.kt @@ -0,0 +1,51 @@ +package com.stslex.atten.core.network.client.error + +import com.stslex.atten.core.core.model.AppError +import com.stslex.atten.core.network.api.AuthApiClient +import com.stslex.atten.core.network.api.ErrorHandler +import io.ktor.client.plugins.ResponseException +import io.ktor.client.request.HttpRequest +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.koin.core.annotation.Singleton + +@Single +@Singleton +internal class ErrorHandlerImpl( + private val authClient: Lazy +) : ErrorHandler { + + private var refreshJob: Job? = null + + override suspend fun invoke(cause: Throwable, request: HttpRequest) { + when { + cause is ErrorRepeatEnd -> throw cause.cause + cause !is ResponseException -> throw cause + cause.response.status.value == Unauthorized.value -> refreshToken(cause) + else -> throw cause + } + } + + private suspend fun refreshToken(cause: Throwable) { + if (refreshJob?.isActive == true) return + refreshJob = coroutineScope { + launch { + authClient.value.refresh() + // TODO remove error throw after refresh token (move logic out from throwing) + throw ErrorRepeatEnd(cause) + } + } + } + + /** + * Error repeat request. + * Show that the request was repeated after a refresh token + * @see com.stslex.atten.core.network.client.client.AppHttpApiImpl.request + */ + private data class ErrorRepeatEnd( + override val cause: Throwable + ) : AppError("", cause) +} diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt similarity index 84% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt rename to core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt index a133787..7ad85f7 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/KtorLogger.kt +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt @@ -1,4 +1,4 @@ -package com.stslex.atten.core.network +package com.stslex.atten.core.network.client.error import com.stslex.atten.core.core.logger.Log import io.ktor.client.plugins.logging.Logger diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt similarity index 71% rename from core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt rename to core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt index 11e11e5..ca71c33 100644 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/RefreshTokenValidator.kt +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt @@ -1,12 +1,12 @@ -package com.stslex.atten.core.network.error_handler +package com.stslex.atten.core.network.client.error -import com.stslex.atten.core.network.model.NetworkError.ErrorRefresh +import com.stslex.atten.core.network.api.model.NetworkError import io.ktor.client.HttpClient import io.ktor.client.plugins.CallRequestExceptionHandler import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.ResponseException import io.ktor.client.request.HttpRequest -import io.ktor.http.HttpStatusCode +import io.ktor.http.HttpStatusCode.Companion.Unauthorized internal object RefreshTokenValidator : CallRequestExceptionHandler { @@ -19,9 +19,8 @@ internal object RefreshTokenValidator : CallRequestExceptionHandler { override suspend fun invoke(cause: Throwable, request: HttpRequest) { throw when { cause !is ResponseException -> cause - cause.response.status.value == HttpStatusCode.Unauthorized.value -> ErrorRefresh(cause) + cause.response.status.value == Unauthorized.value -> NetworkError.ErrorRefresh(cause) else -> cause } } -} - +} \ No newline at end of file diff --git a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt b/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt deleted file mode 100644 index a309ee3..0000000 --- a/core/network/src/commonMain/kotlin/com/stslex/atten/core/network/error_handler/ErrorHandlerImpl.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.stslex.atten.core.network.error_handler - -import com.stslex.atten.core.network.client.AppHttpClient -import com.stslex.atten.core.network.error_handler.RefreshTokenValidator.setupResponseValidator -import com.stslex.atten.core.network.model.NetworkError -import com.stslex.atten.core.network.model.TokenResponseModel -import com.stslex.atten.core.store.user.UserStore -import io.ktor.client.call.body -import io.ktor.client.plugins.ResponseException -import io.ktor.client.request.HttpRequest -import io.ktor.client.request.bearerAuth -import io.ktor.client.request.get -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import org.koin.core.annotation.Single -import org.koin.core.annotation.Singleton - -@Single -@Singleton -internal class ErrorHandlerImpl( - private val appClient: Lazy, - private val userStore: UserStore, -) : ErrorHandler { - - private var refreshJob: Job? = null - - override suspend fun invoke(cause: Throwable, request: HttpRequest) { - when { - cause !is ResponseException -> throw cause - cause.response.status.value == HttpStatusCode.Unauthorized.value -> refreshToken() - else -> throw cause - } - } - - private suspend fun refreshToken() { - if (refreshJob?.isActive == true) return - refreshJob = coroutineScope { - launch { - val tokenResponse = appClient.value - .client - .setupResponseValidator() - .get("auth/refresh") { - bearerAuth(userStore.refreshToken.value) - } - .body() - userStore.uuid.value = tokenResponse.uuid - userStore.refreshToken.value = tokenResponse.refreshToken - userStore.accessToken.value = tokenResponse.refreshToken - userStore.email.value = tokenResponse.email - // TODO remove error throw after refresh token (move logic out from throwing) - throw NetworkError.ErrorRepeatEnd - } - } - } -} \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 626b866..673d3bb 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -52,7 +52,6 @@ 66DD17182BD9630A2D3BC2E0 /* Pods-iosApp.debug.xcconfig */, B16E9FD7FEB4AA463BBA434E /* Pods-iosApp.release.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -170,10 +169,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iosApp/Pods-iosApp-resources.sh\"\n"; @@ -339,6 +342,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = U26KC3ZYKX; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; @@ -360,6 +364,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = U26KC3ZYKX; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c4be94..ab7b108 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,8 @@ include(":core:ui:mvi") include(":core:database") include(":core:paging") include(":core:todo") -include(":core:network") +include(":core:network:api") +include(":core:network:client") include(":core:auth") include(":core:store")