From 53fbcc044ea63a7fc1820dc71a3b195c7d76bae9 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 12:43:32 +0200 Subject: [PATCH 1/6] Introduce :foundations module This module should be the place to keep the basic types shared between the modules. --- app/build.gradle.kts | 2 ++ .../java/com/gravatar/app/di/AppModule.kt | 1 + .../com/gravatar/app/di/DispatcherModule.kt | 22 +++++++++++++++++++ build.gradle.kts | 1 + foundations/.gitignore | 1 + foundations/build.gradle.kts | 17 ++++++++++++++ .../app/foundations/DispatcherProvider.kt | 9 ++++++++ gradle/libs.versions.toml | 4 ++++ settings.gradle.kts | 1 + 9 files changed, 58 insertions(+) create mode 100644 app/src/main/java/com/gravatar/app/di/DispatcherModule.kt create mode 100644 foundations/.gitignore create mode 100644 foundations/build.gradle.kts create mode 100644 foundations/src/main/java/com/gravatar/app/foundations/DispatcherProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9b15fe0..fa9d2464 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":homeUi")) implementation(project(":loginUi")) implementation(project(":analytics")) + implementation(project(":foundations")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -19,6 +20,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.material3) implementation(libs.androidx.navigation) + implementation(libs.kotlinx.coroutines) implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) implementation(libs.koin.android) diff --git a/app/src/main/java/com/gravatar/app/di/AppModule.kt b/app/src/main/java/com/gravatar/app/di/AppModule.kt index 9c62a323..f5bf436d 100644 --- a/app/src/main/java/com/gravatar/app/di/AppModule.kt +++ b/app/src/main/java/com/gravatar/app/di/AppModule.kt @@ -10,5 +10,6 @@ val appModule = module { homeUiModule, loginUiModule, analyticsModule, + dispatcherModule, ) } diff --git a/app/src/main/java/com/gravatar/app/di/DispatcherModule.kt b/app/src/main/java/com/gravatar/app/di/DispatcherModule.kt new file mode 100644 index 00000000..68ba6ca2 --- /dev/null +++ b/app/src/main/java/com/gravatar/app/di/DispatcherModule.kt @@ -0,0 +1,22 @@ +package com.gravatar.app.di + +import com.gravatar.app.foundations.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import org.koin.dsl.module + +val dispatcherModule = module { + single { + AppDispatcherProvider( + main = Dispatchers.Main, + io = Dispatchers.IO, + default = Dispatchers.Default + ) + } +} + +data class AppDispatcherProvider( + override val main: CoroutineDispatcher, + override val io: CoroutineDispatcher, + override val default: CoroutineDispatcher +) : DispatcherProvider diff --git a/build.gradle.kts b/build.gradle.kts index 67713e48..a9eca6e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.jvm) apply false } diff --git a/foundations/.gitignore b/foundations/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/foundations/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/foundations/build.gradle.kts b/foundations/build.gradle.kts new file mode 100644 index 00000000..509c50fd --- /dev/null +++ b/foundations/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + + dependencies { + implementation(libs.kotlinx.coroutines) + } +} +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} diff --git a/foundations/src/main/java/com/gravatar/app/foundations/DispatcherProvider.kt b/foundations/src/main/java/com/gravatar/app/foundations/DispatcherProvider.kt new file mode 100644 index 00000000..e2c6419b --- /dev/null +++ b/foundations/src/main/java/com/gravatar/app/foundations/DispatcherProvider.kt @@ -0,0 +1,9 @@ +package com.gravatar.app.foundations + +import kotlinx.coroutines.CoroutineDispatcher + +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5dfea4e8..43386f4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,8 +14,11 @@ roborazzi = "1.45.1" robolectric = "4.14.1" tracks = "6.0.3" browser = "1.8.0" +kotlinCoroutines = "1.10.2" [libraries] +kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinCoroutines" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -57,6 +60,7 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } # Plugins defined by this project gravatar-android-application = { id = "gravatar.android.application" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 873a70c1..6fb8e5e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,3 +33,4 @@ include(":app") include(":homeUi") include(":loginUi") include(":testUtils") +include(":foundations") From d4e6a2fd0db9d82ba7bae0120c17d7e4d3da956e Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 12:46:33 +0200 Subject: [PATCH 2/6] Configure network_security_config to monitor network requests --- app/src/main/AndroidManifest.xml | 1 + app/src/main/res/xml/network_security_config.xml | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 09ec25c5..169b475b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Gravatar" diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..bd229b92 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file From ea901c195fbfe595586292b8c5cd86299cd95ab9 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 12:50:19 +0200 Subject: [PATCH 3/6] Create :userComponent module for handling the user related operations --- gradle/libs.versions.toml | 6 ++ settings.gradle.kts | 1 + userComponent/.gitignore | 1 + userComponent/build.gradle.kts | 22 +++++++ userComponent/consumer-rules.pro | 0 userComponent/proguard-rules.pro | 21 +++++++ .../usercomponent/data/RealAuthRepository.kt | 20 ++++++ .../app/usercomponent/data/WordPressClient.kt | 63 +++++++++++++++++++ .../app/usercomponent/di/HttpClientModule.kt | 22 +++++++ .../usercomponent/di/UserComponentModule.kt | 14 +++++ .../domain/model/LoginRequest.kt | 8 +++ .../domain/repository/AuthRepository.kt | 8 +++ 12 files changed, 186 insertions(+) create mode 100644 userComponent/.gitignore create mode 100644 userComponent/build.gradle.kts create mode 100644 userComponent/consumer-rules.pro create mode 100644 userComponent/proguard-rules.pro create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/RealAuthRepository.kt create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/WordPressClient.kt create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/model/LoginRequest.kt create mode 100644 userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/repository/AuthRepository.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43386f4e..cd244e71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ roborazzi = "1.45.1" robolectric = "4.14.1" tracks = "6.0.3" browser = "1.8.0" +ktor = "3.1.3" kotlinCoroutines = "1.10.2" [libraries] @@ -39,12 +40,17 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } koin-core = { module = "io.insert-koin:koin-core" } koin-android = { module = "io.insert-koin:koin-android" } +koin-compose = { module = "io.insert-koin:koin-androidx-compose" } koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6fb8e5e2..cdd558ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,4 +33,5 @@ include(":app") include(":homeUi") include(":loginUi") include(":testUtils") +include(":userComponent") include(":foundations") diff --git a/userComponent/.gitignore b/userComponent/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/userComponent/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/userComponent/build.gradle.kts b/userComponent/build.gradle.kts new file mode 100644 index 00000000..e292be38 --- /dev/null +++ b/userComponent/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.gravatar.android.library) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.gravatar.app.usercomponent" +} + +dependencies { + implementation(project(":foundations")) + + implementation(libs.kotlinx.coroutines) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.ktor.core) + implementation(libs.ktor.okhttp) + implementation(libs.ktor.content.negotiation) + implementation(libs.ktor.serialization.json) + + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/userComponent/consumer-rules.pro b/userComponent/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/userComponent/proguard-rules.pro b/userComponent/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/userComponent/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/RealAuthRepository.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/RealAuthRepository.kt new file mode 100644 index 00000000..d42347a9 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/RealAuthRepository.kt @@ -0,0 +1,20 @@ +package com.gravatar.app.usercomponent.data + +import com.gravatar.app.usercomponent.domain.model.LoginRequest +import com.gravatar.app.usercomponent.domain.repository.AuthRepository + +internal class RealAuthRepository( + private val wordPressClient: WordPressClient, +) : AuthRepository { + override suspend fun login(loginRequest: LoginRequest): Result { + return wordPressClient.login( + code = loginRequest.code, + clientSecret = loginRequest.clientSecret, + redirectUri = loginRequest.redirectUri, + clientId = loginRequest.clientId + ).fold( + onSuccess = { Result.success(Unit) }, + onFailure = { Result.failure(it) } + ) + } +} diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/WordPressClient.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/WordPressClient.kt new file mode 100644 index 00000000..4fb3226b --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/WordPressClient.kt @@ -0,0 +1,63 @@ +package com.gravatar.app.usercomponent.data + +import com.gravatar.app.foundations.DispatcherProvider +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.forms.submitForm +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.http.parameters +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +internal class WordPressClient( + private val httpClient: HttpClient, + private val dispatcherProvider: DispatcherProvider, +) { + + companion object { + private const val BASE_URL = "https://public-api.wordpress.com" + } + + @Suppress("TooGenericExceptionCaught") + suspend fun login( + code: String, + clientSecret: String, + redirectUri: String, + clientId: String + ): Result = withContext(dispatcherProvider.io) { + try { + val response = httpClient.submitForm( + url = "$BASE_URL/oauth2/token", + formParameters = parameters { + append("code", code) + append("client_secret", clientSecret) + append("redirect_uri", redirectUri) + append("client_id", clientId) + append("grant_type", "authorization_code") + } + ) { + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val wpToken: WordPressOAuthToken = response.body() + Result.success(wpToken.accessToken) + } else { + Result.failure( + Exception("Failed to login: ${response.status.value} ${response.status.description}") + ) + } + } catch (ex: Exception) { + return@withContext Result.failure( + Exception("Failed to login: ${ex.message}", ex) + ) + } + } +} + +@Serializable +private data class WordPressOAuthToken( + @SerialName("access_token") val accessToken: String, +) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt new file mode 100644 index 00000000..5bc8f0d5 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt @@ -0,0 +1,22 @@ +package com.gravatar.app.usercomponent.di + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import org.koin.dsl.module + +internal val httpClientModule = module { + single { + HttpClient(OkHttp) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + } + ) + } + } + } +} diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt new file mode 100644 index 00000000..4419ce78 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt @@ -0,0 +1,14 @@ +package com.gravatar.app.usercomponent.di + +import com.gravatar.app.usercomponent.data.RealAuthRepository +import com.gravatar.app.usercomponent.data.WordPressClient +import com.gravatar.app.usercomponent.domain.repository.AuthRepository +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val userComponentModule = module { + factoryOf(::RealAuthRepository) { bind() } + factoryOf(::WordPressClient) + includes(httpClientModule) +} diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/model/LoginRequest.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/model/LoginRequest.kt new file mode 100644 index 00000000..87a2a0c4 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/model/LoginRequest.kt @@ -0,0 +1,8 @@ +package com.gravatar.app.usercomponent.domain.model + +data class LoginRequest( + val code: String, + val clientSecret: String, + val redirectUri: String, + val clientId: String +) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/repository/AuthRepository.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/repository/AuthRepository.kt new file mode 100644 index 00000000..e0f9c032 --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/repository/AuthRepository.kt @@ -0,0 +1,8 @@ +package com.gravatar.app.usercomponent.domain.repository + +import com.gravatar.app.usercomponent.domain.model.LoginRequest + +interface AuthRepository { + + suspend fun login(loginRequest: LoginRequest): Result +} From 83268db0addbae4f41105e91d8825fb6c4b97553 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 12:50:36 +0200 Subject: [PATCH 4/6] Request the token from the LoginScreen --- loginUi/build.gradle.kts | 9 ++ .../app/loginUi/di/BuildConfigModule.kt | 3 +- .../gravatar/app/loginUi/di/LoginUiModule.kt | 7 +- .../loginUi/presentation/login/LoginScreen.kt | 85 ++++++++++++++---- .../presentation/login/LoginViewModel.kt | 87 +++++++++++++++++++ .../presentation/oauth/OAuthActivity.kt | 12 +-- .../loginUi/presentation/oauth/OAuthConfig.kt | 1 + 7 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginViewModel.kt diff --git a/loginUi/build.gradle.kts b/loginUi/build.gradle.kts index ed7cfef2..5da9635e 100644 --- a/loginUi/build.gradle.kts +++ b/loginUi/build.gradle.kts @@ -32,6 +32,11 @@ android { "OAUTH_REDIRECT_URI", "\"${properties["oauth.redirectUri"]?.toString() ?: ""}\"", ) + buildConfigField( + "String", + "OAUTH_CLIENT_SECRET", + "\"${properties["oauth.clientSecret"]?.toString() ?: ""}\"", + ) manifestPlaceholders["OAUTH_REDIRECT_URI_HOST"] = properties["oauth.redirectUri"]?.toString()?.split("://")?.get(1) ?: "" manifestPlaceholders["OAUTH_REDIRECT_URI_SCHEME"] = @@ -41,6 +46,9 @@ android { } dependencies { + implementation(project(":foundations")) + implementation(project(":userComponent")) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.material3) implementation(libs.androidx.ui) @@ -50,6 +58,7 @@ dependencies { implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) implementation(libs.koin.android) + implementation(libs.koin.compose) testImplementation(libs.junit) testImplementation(project(":testUtils")) } diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/BuildConfigModule.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/BuildConfigModule.kt index 9d857fbb..14421007 100644 --- a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/BuildConfigModule.kt +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/BuildConfigModule.kt @@ -8,7 +8,8 @@ internal val buildConfigModule = module { single { OAuthConfig( clientId = BuildConfig.OAUTH_CLIENT_ID, - redirectUri = BuildConfig.OAUTH_REDIRECT_URI + redirectUri = BuildConfig.OAUTH_REDIRECT_URI, + clientSecret = BuildConfig.OAUTH_CLIENT_SECRET, ) } } diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/LoginUiModule.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/LoginUiModule.kt index d204c6b3..ecdabd2a 100644 --- a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/LoginUiModule.kt +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/di/LoginUiModule.kt @@ -1,7 +1,12 @@ package com.gravatar.app.loginUi.di +import com.gravatar.app.loginUi.presentation.login.LoginViewModel +import com.gravatar.app.usercomponent.di.userComponentModule +import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module val loginUiModule = module { - includes(buildConfigModule) + includes(buildConfigModule, userComponentModule) + + viewModelOf(::LoginViewModel) } diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreen.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreen.kt index 123be9d8..3fb3e41c 100644 --- a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreen.kt +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreen.kt @@ -7,38 +7,79 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import com.gravatar.app.loginUi.presentation.oauth.OAuthResult +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.gravatar.app.loginUi.presentation.oauth.OAuthResultContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.androidx.compose.koinViewModel @Composable fun LoginScreen( onLoggedIn: () -> Unit, ) { + LoginScreen( + onLoggedIn = onLoggedIn, + viewModel = koinViewModel(), + ) +} + +@Composable +internal fun LoginScreen( + onLoggedIn: () -> Unit, + viewModel: LoginViewModel, +) { + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle - val oAuthLauncher = rememberLauncherForActivityResult(OAuthResultContract()) { result -> - when (result) { - OAuthResult.DISMISSED -> Unit - is OAuthResult.TOKEN -> { - Toast.makeText(context, "Logged in successfully!", Toast.LENGTH_SHORT).show() - onLoggedIn() - } + LaunchedEffect(Unit) { + withContext(Dispatchers.Main.immediate) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.actions.collect { action -> + when (action) { + LoginAction.UserLoggedIn -> { + onLoggedIn() + } - OAuthResult.ERROR -> { - Toast.makeText(context, "Login failed. Please try again.", Toast.LENGTH_SHORT) - .show() + LoginAction.ShowError -> { + Toast.makeText(context, "Login error", Toast.LENGTH_SHORT) + .show() + } + } + } } } } + LoginScreen( + uiState = uiState, + onEvent = viewModel::onEvent + ) +} + +@Composable +internal fun LoginScreen( + uiState: LoginUiState, + onEvent: (LoginEvent) -> Unit, +) { + val oAuthLauncher = rememberLauncherForActivityResult(OAuthResultContract()) { result -> + onEvent(LoginEvent.OAuthResultReceived(result)) + } + Scaffold { innerPadding -> Surface( modifier = Modifier.padding(innerPadding) @@ -47,14 +88,22 @@ fun LoginScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column { - Text("Login Screen") - Button( - onClick = { - oAuthLauncher.launch(Unit) + when { + uiState.isLoading -> { + CircularProgressIndicator() + } + + else -> { + Column { + Text("Login Screen") + Button( + onClick = { + oAuthLauncher.launch(Unit) + } + ) { + Text("Log In") + } } - ) { - Text("Log In") } } } diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginViewModel.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginViewModel.kt new file mode 100644 index 00000000..0d3e503b --- /dev/null +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/login/LoginViewModel.kt @@ -0,0 +1,87 @@ +package com.gravatar.app.loginUi.presentation.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gravatar.app.loginUi.presentation.oauth.OAuthConfig +import com.gravatar.app.loginUi.presentation.oauth.OAuthResult +import com.gravatar.app.usercomponent.domain.model.LoginRequest +import com.gravatar.app.usercomponent.domain.repository.AuthRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class LoginViewModel( + private val authRepository: AuthRepository, + private val oAuthConfig: OAuthConfig +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions = _actions.receiveAsFlow() + + fun onEvent(event: LoginEvent) { + when (event) { + is LoginEvent.OAuthResultReceived -> handleOAuthResult(event.result) + } + } + + private fun handleOAuthResult(result: OAuthResult) { + when (result) { + OAuthResult.Dismissed -> Unit + is OAuthResult.Token -> login(result.token) + OAuthResult.Error -> sendAction(LoginAction.ShowError) + } + } + + private fun login(token: String) { + viewModelScope.launch { + _uiState.update { + it.copy(isLoading = true) + } + + val loginRequest = LoginRequest( + code = token, + clientSecret = oAuthConfig.clientSecret, + redirectUri = oAuthConfig.redirectUri, + clientId = oAuthConfig.clientId + ) + + authRepository.login(loginRequest) + .onSuccess { + sendAction(LoginAction.UserLoggedIn) + } + .onFailure { error -> + sendAction(LoginAction.ShowError) + } + _uiState.update { + it.copy(isLoading = false) + } + } + } + + private fun sendAction(action: LoginAction) { + viewModelScope.launch(Dispatchers.Main.immediate) { + _actions.send(action) + } + } +} + +internal data class LoginUiState( + val isLoading: Boolean = false, +) + +internal sealed class LoginEvent { + data class OAuthResultReceived(val result: OAuthResult) : LoginEvent() +} + +internal sealed class LoginAction { + data object UserLoggedIn : LoginAction() + data object ShowError : LoginAction() +} diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthActivity.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthActivity.kt index 81862933..f2083244 100644 --- a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthActivity.kt +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthActivity.kt @@ -117,20 +117,20 @@ internal class OAuthResultContract : override fun parseResult(resultCode: Int, intent: Intent?): OAuthResult { return when (intent?.getIntExtra(OAuthActivity.ACTIVITY_RESULT, -1)) { - OAuthActivity.RESULT_TOKEN_RETRIEVED -> OAuthResult.TOKEN( + OAuthActivity.RESULT_TOKEN_RETRIEVED -> OAuthResult.Token( intent.getStringExtra(OAuthActivity.TOKEN_KEY)!!, ) - OAuthActivity.RESULT_TOKEN_ERROR -> OAuthResult.ERROR - else -> OAuthResult.DISMISSED + OAuthActivity.RESULT_TOKEN_ERROR -> OAuthResult.Error + else -> OAuthResult.Dismissed } } } internal sealed class OAuthResult { - data class TOKEN(val token: String) : OAuthResult() + data class Token(val token: String) : OAuthResult() - data object DISMISSED : OAuthResult() + data object Dismissed : OAuthResult() - data object ERROR : OAuthResult() + data object Error : OAuthResult() } diff --git a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthConfig.kt b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthConfig.kt index 505d66e3..81030c9b 100644 --- a/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthConfig.kt +++ b/loginUi/src/main/kotlin/com/gravatar/app/loginUi/presentation/oauth/OAuthConfig.kt @@ -3,4 +3,5 @@ package com.gravatar.app.loginUi.presentation.oauth internal data class OAuthConfig( val clientId: String, val redirectUri: String, + val clientSecret: String, ) From 9d160e28e292b2e7b825ffbe3d8b8435a2bf79f9 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 13:58:38 +0200 Subject: [PATCH 5/6] Provide HttpClientEngine explicitely --- .../com/gravatar/app/usercomponent/di/HttpClientModule.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt index 5bc8f0d5..8da3e73c 100644 --- a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt @@ -1,6 +1,7 @@ package com.gravatar.app.usercomponent.di import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json @@ -8,8 +9,9 @@ import kotlinx.serialization.json.Json import org.koin.dsl.module internal val httpClientModule = module { - single { - HttpClient(OkHttp) { + single { OkHttp.create() } + single { + HttpClient(get()) { install(ContentNegotiation) { json( Json { From 7739174378ba8568b4a52fa31b45f34089621275 Mon Sep 17 00:00:00 2001 From: AdamGrzybkowski Date: Wed, 25 Jun 2025 13:58:46 +0200 Subject: [PATCH 6/6] Fix LoginScreentest --- .../gravatar/app/loginUi/presentation/login/LoginScreenTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loginUi/src/test/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreenTest.kt b/loginUi/src/test/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreenTest.kt index 5fbe5fb5..7d317dc5 100644 --- a/loginUi/src/test/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreenTest.kt +++ b/loginUi/src/test/kotlin/com/gravatar/app/loginUi/presentation/login/LoginScreenTest.kt @@ -9,7 +9,8 @@ class LoginScreenTest : RoborazziTest() { fun loginScreen_captureScreenshot() { screenshotTest { LoginScreen( - onLoggedIn = { } + uiState = LoginUiState(), + onEvent = { } ) } }