diff --git a/commonApp/build.gradle.kts b/commonApp/build.gradle.kts index cd36879..55d902e 100644 --- a/commonApp/build.gradle.kts +++ b/commonApp/build.gradle.kts @@ -14,6 +14,9 @@ kotlin { implementation(project(":core:paging")) implementation(project(":core:todo")) implementation(project(":core:auth")) + implementation(project(":core:network:api")) + implementation(project(":core:network:client")) + 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/api/build.gradle.kts b/core/network/api/build.gradle.kts new file mode 100644 index 0000000..04bb98c --- /dev/null +++ b/core/network/api/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/api/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 new file mode 100644 index 0000000..f75338f --- /dev/null +++ b/core/network/api/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/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt new file mode 100644 index 0000000..06c0337 --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/AppHttpClient.kt @@ -0,0 +1,8 @@ +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/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt new file mode 100644 index 0000000..8a60289 --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/ErrorHandler.kt @@ -0,0 +1,5 @@ +package com.stslex.atten.core.network.api + +import io.ktor.client.plugins.CallRequestExceptionHandler + +interface ErrorHandler : CallRequestExceptionHandler \ No newline at end of file diff --git a/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt new file mode 100644 index 0000000..2a17bfc --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/NetworkError.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.network.api.model + +import com.stslex.atten.core.core.model.AppError + +sealed class NetworkError( + message: String, + cause: Throwable? = null +) : AppError( + message = message, + cause = cause, +) { + + data class ErrorRefresh( + override val cause: Throwable, + ) : NetworkError("success token not valid", cause) +} \ No newline at end of file diff --git a/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt new file mode 100644 index 0000000..b4e0348 --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/TokenResponseModel.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.network.api.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/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/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt new file mode 100644 index 0000000..5ea34fe --- /dev/null +++ b/core/network/api/src/commonMain/kotlin/com/stslex/atten/core/network/api/model/response/LoginOkResponse.kt @@ -0,0 +1,16 @@ +package com.stslex.atten.core.network.api.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/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/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt new file mode 100644 index 0000000..c76f41f --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpApiImpl.kt @@ -0,0 +1,23 @@ +package com.stslex.atten.core.network.client.client + +import com.stslex.atten.core.core.coroutine.dispatcher.AppDispatcher +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 +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/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt new file mode 100644 index 0000000..66ba288 --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/client/AppHttpClientImpl.kt @@ -0,0 +1,104 @@ +package com.stslex.atten.core.network.client.client + +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 +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.Companion) + expectSuccess = true + HttpResponseValidator { handleResponseExceptionWithRequest(errorHandler) } + setupDefaultRequest() + setupNegotiation() + setupLogging() + installAuth() + } + + 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.Companion.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/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/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt new file mode 100644 index 0000000..2c3048e --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/di/ModuleCoreNetwork.kt @@ -0,0 +1,10 @@ +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.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/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt new file mode 100644 index 0000000..7ad85f7 --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/KtorLogger.kt @@ -0,0 +1,14 @@ +package com.stslex.atten.core.network.client.error + +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/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt new file mode 100644 index 0000000..ca71c33 --- /dev/null +++ b/core/network/client/src/commonMain/kotlin/com/stslex/atten/core/network/client/error/RefreshTokenValidator.kt @@ -0,0 +1,26 @@ +package com.stslex.atten.core.network.client.error + +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.Companion.Unauthorized + +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 == Unauthorized.value -> NetworkError.ErrorRefresh(cause) + else -> cause + } + } +} \ 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/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 34ef1e4..ab7b108 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,7 +26,10 @@ include(":core:ui:mvi") include(":core:database") include(":core:paging") include(":core:todo") +include(":core:network:api") +include(":core:network:client") include(":core:auth") +include(":core:store") include(":feature:home") include(":feature:details")