From 463e490827e7e14b248fd5e674a998b4ae2084b4 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 18:30:01 +0530 Subject: [PATCH 01/44] Add LoginButton composable and integrate it into MainActivity --- .../java/com/notifier/app/MainActivity.kt | 26 +++---------------- .../app/auth/presentation/LoginButton.kt | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 5f178ef..bac8266 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -7,10 +7,8 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.auth.presentation.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -22,28 +20,12 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) + LoginButton( + modifier = Modifier.padding(innerPadding), + onClick = {} ) } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - GitHubNotifierTheme { - Greeting("Android") - } -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt new file mode 100644 index 0000000..2b8bd6b --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt @@ -0,0 +1,26 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + modifier = modifier, + onClick = onClick, + ) { + Text("Login with GitHub") + } +} + +@Preview +@Composable +private fun LoginButtonPreview() { + GitHubNotifierTheme { + LoginButton(onClick = {}) + } +} From d084a29f37e4bc2dbb8cd03e31d41424c73b0a64 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 18:39:42 +0530 Subject: [PATCH 02/44] Add CLIENT_ID and CLIENT_SECRET to buildConfig in debug and release builds --- app/build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f652653..79b735f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -22,12 +24,20 @@ android { } buildTypes { + val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) + } + debug { buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") + buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") } release { isMinifyEnabled = false buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") + buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") + buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" From ed82b5a4438e5ef0d5e311f5d340d02e04e02848 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 19:33:08 +0530 Subject: [PATCH 03/44] Add GitHub OAuth integration with LoginButton and createGitHubAuthIntent --- .../java/com/notifier/app/MainActivity.kt | 7 ++---- .../{ => components}/LoginButton.kt | 13 +++++++---- .../util/createGitHubAuthIntent.kt | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) rename app/src/main/java/com/notifier/app/auth/presentation/{ => components}/LoginButton.kt (55%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index bac8266..9dacf97 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import com.notifier.app.auth.presentation.LoginButton +import com.notifier.app.auth.presentation.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,10 +20,7 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginButton( - modifier = Modifier.padding(innerPadding), - onClick = {} - ) + LoginButton(modifier = Modifier.padding(innerPadding)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt similarity index 55% rename from app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt rename to app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt index 2b8bd6b..392b534 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt @@ -1,17 +1,22 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.components import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable -fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { +fun LoginButton(modifier: Modifier = Modifier) { + val context = LocalContext.current Button( modifier = modifier, - onClick = onClick, + onClick = { + context.startActivity(createGitHubAuthIntent()) + }, ) { Text("Login with GitHub") } @@ -21,6 +26,6 @@ fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { @Composable private fun LoginButtonPreview() { GitHubNotifierTheme { - LoginButton(onClick = {}) + LoginButton() } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt new file mode 100644 index 0000000..8205579 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt @@ -0,0 +1,22 @@ +package com.notifier.app.auth.presentation.util + +import android.content.Intent +import androidx.core.net.toUri +import com.notifier.app.BuildConfig +import java.util.UUID + +private const val GITHUB_AUTH_BASE_URL = "https://github.com/login/oauth/authorize" + +/** + * Creates an [Intent] to launch the GitHub OAuth authorization page in a browser. + * + * @return [Intent] configured with the GitHub OAuth URL and required parameters. + */ +fun createGitHubAuthIntent(): Intent { + val clientId = BuildConfig.CLIENT_ID + val state = UUID.randomUUID().toString() + + val authUrl = "$GITHUB_AUTH_BASE_URL?client_id=$clientId&state=$state" + + return Intent(Intent.ACTION_VIEW, authUrl.toUri()) +} From 7058ed3d8d3a2a7acc3885d02bb1b466eaad0797 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 27 Apr 2025 20:24:54 +0530 Subject: [PATCH 04/44] Add LoginScreen composable and update MainActivity to use it --- app/src/main/AndroidManifest.xml | 11 +++++- .../java/com/notifier/app/MainActivity.kt | 4 +- .../app/auth/presentation/LoginScreen.kt | 38 +++++++++++++++++++ .../presentation/components/LoginButton.kt | 6 ++- 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fcb03..7334fe1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,9 +14,18 @@ android:exported="true"> - + + + + + + + + diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 9dacf97..c8b9bbd 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.auth.presentation.LoginScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +20,7 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginButton(modifier = Modifier.padding(innerPadding)) + LoginScreen(modifier = Modifier.padding(innerPadding)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt new file mode 100644 index 0000000..bff0113 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt @@ -0,0 +1,38 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoginButton() + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun LoginScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + LoginScreen(modifier = Modifier.padding(innerPadding)) + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt index 392b534..664896a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt @@ -5,7 +5,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -22,7 +23,8 @@ fun LoginButton(modifier: Modifier = Modifier) { } } -@Preview +@PreviewLightDark +@PreviewDynamicColors @Composable private fun LoginButtonPreview() { GitHubNotifierTheme { From bbedd44b26fd2aec8c74ceb85e7828168cf4fedb Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 18:44:20 +0530 Subject: [PATCH 05/44] Add authentication feature with ViewModel, data sources, and error handling --- app/build.gradle.kts | 1 + .../java/com/notifier/app/MainActivity.kt | 31 +++++++++++++++- .../app/auth/data/mappers/AuthTokenMapper.kt | 10 ++++++ .../networking/RemoteAuthTokenDataSource.kt | 36 +++++++++++++++++++ .../networking/dto/AuthTokenResponseDto.kt | 14 ++++++++ .../com/notifier/app/auth/domain/AuthToken.kt | 7 ++++ .../app/auth/domain/AuthTokenDataSource.kt | 12 +++++++ .../app/auth/presentation/AuthAction.kt | 5 +++ .../app/auth/presentation/AuthEvent.kt | 7 ++++ .../app/auth/presentation/AuthState.kt | 10 ++++++ .../app/auth/presentation/AuthViewModel.kt | 34 ++++++++++++++++++ .../app/auth/presentation/LoginScreen.kt | 8 ++++- .../notifier/app/core/presentation/.gitkeep | 0 .../presentation/util/NetworkErrorToString.kt | 17 +++++++++ .../core/presentation/util/ObserveAsEvents.kt | 27 ++++++++++++++ .../java/com/notifier/app/di/ApiModule.kt | 12 +++++++ app/src/main/res/values/strings.xml | 5 +++ gradle/libs.versions.toml | 10 +++--- 18 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt create mode 100644 app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt create mode 100644 app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt create mode 100644 app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt create mode 100644 app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/.gitkeep create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79b735f..a0b3061 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) implementation(libs.bundles.ktor) implementation(libs.dagger.hilt) ksp(libs.dagger.hilt.compiler) diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index c8b9bbd..62dafa0 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -1,14 +1,23 @@ package com.notifier.app import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.auth.presentation.AuthEvent +import com.notifier.app.auth.presentation.AuthViewModel import com.notifier.app.auth.presentation.LoginScreen +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -20,7 +29,27 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - LoginScreen(modifier = Modifier.padding(innerPadding)) + val viewModel: AuthViewModel by viewModels() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + LoginScreen( + state = state, + onAction = { action -> + viewModel.onAction(action) + }, + modifier = Modifier.padding(innerPadding) + ) } } } diff --git a/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt b/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt new file mode 100644 index 0000000..9dbb167 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/mappers/AuthTokenMapper.kt @@ -0,0 +1,10 @@ +package com.notifier.app.auth.data.mappers + +import com.notifier.app.auth.data.networking.dto.AuthTokenResponseDto +import com.notifier.app.auth.domain.AuthToken + +fun AuthTokenResponseDto.toAuthToken() = AuthToken( + accessToken = accessToken, + scope = scope, + tokenType = tokenType, +) diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt new file mode 100644 index 0000000..d55c47e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt @@ -0,0 +1,36 @@ +package com.notifier.app.auth.data.networking + +import com.notifier.app.auth.data.mappers.toAuthToken +import com.notifier.app.auth.data.networking.dto.AuthTokenResponseDto +import com.notifier.app.auth.domain.AuthToken +import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.data.networking.safeCall +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Result +import com.notifier.app.core.domain.util.map +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import javax.inject.Inject + +class RemoteAuthTokenDataSource @Inject constructor( + private val httpClient: HttpClient, +) : AuthTokenDataSource { + override suspend fun getAuthToken( + clientId: String, + clientSecret: String, + code: String, + ): Result { + return safeCall { + httpClient.get( + urlString = "https://github.com/login/oauth/access_token" + ) { + parameter("client_id", clientId) + parameter("client_secret", clientSecret) + parameter("code", code) + } + }.map { response -> + response.toAuthToken() + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt b/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt new file mode 100644 index 0000000..e0ff727 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/data/networking/dto/AuthTokenResponseDto.kt @@ -0,0 +1,14 @@ +package com.notifier.app.auth.data.networking.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthTokenResponseDto( + @SerialName("access_token") + val accessToken: String, + @SerialName("scope") + val scope: String, + @SerialName("token_type") + val tokenType: String, +) diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt new file mode 100644 index 0000000..0c8f750 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.domain + +data class AuthToken( + val accessToken: String, + val scope: String, + val tokenType: String, +) diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt new file mode 100644 index 0000000..eb69669 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt @@ -0,0 +1,12 @@ +package com.notifier.app.auth.domain + +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Result + +interface AuthTokenDataSource { + suspend fun getAuthToken( + clientId: String, + clientSecret: String, + code: String, + ): Result +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt new file mode 100644 index 0000000..57ed033 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt @@ -0,0 +1,5 @@ +package com.notifier.app.auth.presentation + +sealed interface AuthAction { + data object OnLoginButtonClick : AuthAction +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt new file mode 100644 index 0000000..6c21299 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface AuthEvent { + data class Error(val error: NetworkError) : AuthEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt new file mode 100644 index 0000000..1ee1a5f --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt @@ -0,0 +1,10 @@ +package com.notifier.app.auth.presentation + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class AuthState( + val isLoading: Boolean = false, + val authToken: AuthToken? = null, +) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt new file mode 100644 index 0000000..2b8909e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt @@ -0,0 +1,34 @@ +package com.notifier.app.auth.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.notifier.app.auth.domain.AuthTokenDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val authTokenDataSource: AuthTokenDataSource, +) : ViewModel() { + private val _state = MutableStateFlow(AuthState()) + val state = _state + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + AuthState() + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: AuthAction) { + when (action) { + is AuthAction.OnLoginButtonClick -> {} + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt index bff0113..3e6b03d 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt @@ -15,6 +15,8 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun LoginScreen( + state: AuthState, + onAction: (AuthAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -32,7 +34,11 @@ fun LoginScreen( private fun LoginScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> - LoginScreen(modifier = Modifier.padding(innerPadding)) + LoginScreen( + state = AuthState(), + onAction = {}, + modifier = Modifier.padding(innerPadding) + ) } } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/.gitkeep b/app/src/main/java/com/notifier/app/core/presentation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt new file mode 100644 index 0000000..df4152b --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/NetworkErrorToString.kt @@ -0,0 +1,17 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import com.notifier.app.R +import com.notifier.app.core.domain.util.NetworkError + +fun NetworkError.toString(context: Context): String { + val resId = when (this) { + NetworkError.REQUEST_TIMEOUT -> R.string.error_request_timeout + NetworkError.TOO_MANY_REQUESTS -> R.string.error_too_many_requests + NetworkError.NO_INTERNET -> R.string.error_no_internet + NetworkError.SERVER_ERROR -> R.string.error_unknown + NetworkError.SERIALIZATION -> R.string.error_serialization + NetworkError.UNKNOWN -> R.string.error_unknown + } + return context.getString(resId) +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt new file mode 100644 index 0000000..37ae42f --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package com.notifier.app.core.presentation.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + events: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner.lifecycle, key1, key2) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + events.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/notifier/app/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index e4dd221..ba92f5b 100644 --- a/app/src/main/java/com/notifier/app/di/ApiModule.kt +++ b/app/src/main/java/com/notifier/app/di/ApiModule.kt @@ -1,5 +1,7 @@ package com.notifier.app.di +import com.notifier.app.auth.data.networking.RemoteAuthTokenDataSource +import com.notifier.app.auth.domain.AuthTokenDataSource import com.notifier.app.core.data.networking.HttpClientFactory import dagger.Module import dagger.Provides @@ -7,12 +9,22 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ApiModule { @Provides + @Singleton fun provideHttpClient(): HttpClient { return HttpClientFactory.create(CIO.create()) } + + @Provides + @Singleton + fun provideRemoteAuthTokenDataSource( + httpClient: HttpClient, + ): AuthTokenDataSource { + return RemoteAuthTokenDataSource(httpClient) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9719f0..bccf8e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,8 @@ GitHub Notifier + The request timed out. + Oops, it seems like your quota is exceeded. + Couldn\'t connect to server, please check your internet connection. + Something went wrong. Please try again later. + Couldn\'t serialize or deserialize data. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c35a32c..9519b1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,8 @@ truth = "1.1.3" kotlinxCoroutinesTest = "1.10.1" mockk = "1.13.8" ksp = "2.0.21-1.0.27" -dagger-hilt = "2.56.1" +daggerHilt = "2.56.1" +navigation = "2.8.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } @@ -39,8 +41,8 @@ ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } -dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" } -dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" } +dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } +dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -48,7 +50,7 @@ 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" } +dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "daggerHilt" } [bundles] ktor = [ From 1b7b3b5acc07dacbf0aa1d3c21c586b5016e0e91 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 20:08:07 +0530 Subject: [PATCH 06/44] Add ConnectingToGitHubScreen and navigation setup in MainActivity --- .../java/com/notifier/app/MainActivity.kt | 66 ++++++++++++++----- .../connecting/ConnectingToGitHubScreen.kt | 42 ++++++++++++ .../routes/ConnectingToGitHubScreen.kt | 9 +++ .../navigation/routes/LoginScreen.kt | 6 ++ 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 62dafa0..8b1b6e4 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -1,6 +1,7 @@ package com.notifier.app import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -13,9 +14,17 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navDeepLink +import androidx.navigation.toRoute import com.notifier.app.auth.presentation.AuthEvent import com.notifier.app.auth.presentation.AuthViewModel +import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubScreen import com.notifier.app.auth.presentation.LoginScreen +import com.notifier.app.core.presentation.navigation.routes.ConnectingToGitHubScreen +import com.notifier.app.core.presentation.navigation.routes.LoginScreen import com.notifier.app.core.presentation.util.ObserveAsEvents import com.notifier.app.core.presentation.util.toString import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -29,27 +38,48 @@ class MainActivity : ComponentActivity() { setContent { GitHubNotifierTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - val viewModel: AuthViewModel by viewModels() - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - ObserveAsEvents(events = viewModel.events) { event -> - when (event) { - is AuthEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + val viewModel: AuthViewModel by viewModels() + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } } + LoginScreen( + state = state, + onAction = { action -> + viewModel.onAction(action) + } + ) + } + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val code = it.toRoute().code + val state = it.toRoute().state + Log.d("MainActivity", "Code: $code") + Log.d("MainActivity", "State: $state") + ConnectingToGitHubScreen() } } - LoginScreen( - state = state, - onAction = { action -> - viewModel.onAction(action) - }, - modifier = Modifier.padding(innerPadding) - ) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt new file mode 100644 index 0000000..1301ccb --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt @@ -0,0 +1,42 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun ConnectingToGitHubScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Connecting to GitHub...", + ) + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun ConnectingToGitHubScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + ConnectingToGitHubScreen( + modifier = Modifier.padding(innerPadding) + ) + } + } +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt new file mode 100644 index 0000000..3921109 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt @@ -0,0 +1,9 @@ +package com.notifier.app.core.presentation.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectingToGitHubScreen( + val code: String? = null, + val state: String? = null, +) diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt new file mode 100644 index 0000000..9898070 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt @@ -0,0 +1,6 @@ +package com.notifier.app.core.presentation.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen From 9c6c7acebc2107aff0c30fbf5c890ba4bf54926a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 28 Apr 2025 22:18:37 +0530 Subject: [PATCH 07/44] Update build.gradle.kts to handle missing local.properties gracefully and provide default values for CLIENT_ID and CLIENT_SECRET --- app/build.gradle.kts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0b3061..a204b51 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,19 +25,38 @@ android { buildTypes { val properties = Properties().apply { - load(project.rootProject.file("local.properties").inputStream()) + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + load(localPropertiesFile.inputStream()) + } } debug { buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") - buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") - buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") + buildConfigField( + "String", + "CLIENT_ID", + "\"${properties.getProperty("CLIENT_ID", "dummy_client_id")}\"" + ) + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${properties.getProperty("CLIENT_SECRET", "dummy_client_secret")}\"" + ) } release { isMinifyEnabled = false buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"") - buildConfigField("String", "CLIENT_ID", "\"${properties.getProperty("CLIENT_ID")}\"") - buildConfigField("String", "CLIENT_SECRET", "\"${properties.getProperty("CLIENT_SECRET")}\"") + buildConfigField( + "String", + "CLIENT_ID", + "\"${properties.getProperty("CLIENT_ID", "dummy_client_id")}\"" + ) + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${properties.getProperty("CLIENT_SECRET", "dummy_client_secret")}\"" + ) proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" From 2cda0cc8e19ba9e408735d7f7c57f5bf37dd29ae Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:38:52 +0530 Subject: [PATCH 08/44] Add GitHub connection handling with state management and navigation setup --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 2 + .../java/com/notifier/app/MainActivity.kt | 90 +++++++------------ .../networking/RemoteAuthTokenDataSource.kt | 4 +- .../connecting/ConnectingToGitHubEvent.kt | 7 ++ .../connecting/ConnectingToGitHubRoute.kt | 45 ++++++++++ .../connecting/ConnectingToGitHubScreen.kt | 25 +++++- .../connecting/ConnectingToGitHubState.kt | 17 ++++ .../connecting/ConnectingToGitHubViewModel.kt | 67 ++++++++++++++ .../presentation/{ => login}/AuthAction.kt | 2 +- .../presentation/{ => login}/AuthEvent.kt | 2 +- .../presentation/{ => login}/AuthState.kt | 2 +- .../presentation/{ => login}/AuthViewModel.kt | 2 +- .../app/auth/presentation/login/LoginRoute.kt | 40 +++++++++ .../presentation/{ => login}/LoginScreen.kt | 4 +- .../{ => login}/components/LoginButton.kt | 2 +- .../routes/ConnectingToGitHubScreen.kt | 9 -- .../navigation/routes/LoginScreen.kt | 6 -- gradle/libs.versions.toml | 2 + 19 files changed, 246 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthAction.kt (62%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthEvent.kt (74%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthState.kt (80%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/AuthViewModel.kt (95%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/LoginScreen.kt (90%) rename app/src/main/java/com/notifier/app/auth/presentation/{ => login}/components/LoginButton.kt (93%) delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt delete mode 100644 app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a204b51..c438ecd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,8 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.bundles.ktor) implementation(libs.dagger.hilt) + implementation(libs.hilt.navigation.compose) + ksp(libs.dagger.hilt.compiler) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7334fe1..7f1665b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = LoginScreen, - modifier = Modifier.padding(innerPadding) - ) { - composable { - val viewModel: AuthViewModel by viewModels() - val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current - ObserveAsEvents(events = viewModel.events) { event -> - when (event) { - is AuthEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() - } - } - } - LoginScreen( - state = state, - onAction = { action -> - viewModel.onAction(action) - } - ) - } - composable( - deepLinks = listOf( - navDeepLink( - basePath = "github-notifier://auth-callback" - ) - ) - ) { - val code = it.toRoute().code - val state = it.toRoute().state - Log.d("MainActivity", "Code: $code") - Log.d("MainActivity", "State: $state") - ConnectingToGitHubScreen() - } - } - } + MainAppContent() + } + } + } +} + +@Composable +private fun MainAppContent() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + LoginRoute() + } + + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val args = it.toRoute() + ConnectingToGitHubRoute(code = args.code) } } } diff --git a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt index d55c47e..98aaa4a 100644 --- a/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt +++ b/app/src/main/java/com/notifier/app/auth/data/networking/RemoteAuthTokenDataSource.kt @@ -9,8 +9,8 @@ import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.Result import com.notifier.app.core.domain.util.map import io.ktor.client.HttpClient -import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.post import javax.inject.Inject class RemoteAuthTokenDataSource @Inject constructor( @@ -22,7 +22,7 @@ class RemoteAuthTokenDataSource @Inject constructor( code: String, ): Result { return safeCall { - httpClient.get( + httpClient.post( urlString = "https://github.com/login/oauth/access_token" ) { parameter("client_id", clientId) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt new file mode 100644 index 0000000..b32eb37 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation.connecting + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface ConnectingToGitHubEvent { + data class Error(val error: NetworkError) : ConnectingToGitHubEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt new file mode 100644 index 0000000..696574d --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt @@ -0,0 +1,45 @@ +package com.notifier.app.auth.presentation.connecting + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString +import kotlinx.serialization.Serializable + +@Serializable +data class ConnectingToGitHubScreen( + val code: String? = null, + val state: String? = null, +) + +@Composable +fun ConnectingToGitHubRoute( + code: String?, + viewModel: ConnectingToGitHubViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(code) { + viewModel.getAuthToken(code) + } + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is ConnectingToGitHubEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + + ConnectingToGitHubScreen(state = state) +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt index 1301ccb..821f97a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement 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.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun ConnectingToGitHubScreen( + state: ConnectingToGitHubState, modifier: Modifier = Modifier, ) { Column( @@ -22,9 +24,25 @@ fun ConnectingToGitHubScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = "Connecting to GitHub...", - ) + when (state.connectionState) { + ConnectionState.FETCHING_TOKEN -> { + Text(text = "Connecting to GitHub...") + } + ConnectionState.SAVING_TOKEN -> { + Text(text = "Saving user information...") + } + ConnectionState.SUCCESS -> { + Text(text = "Connected successfully!") + Button( + onClick = { /* TODO: Navigate to the next screen */ }, + ) { + Text(text = "Continue") + } + } + ConnectionState.FAILED -> { + Text(text = "Connection Failed. Please try again.") + } + } } } @@ -35,6 +53,7 @@ private fun ConnectingToGitHubScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> ConnectingToGitHubScreen( + state = ConnectingToGitHubState(), modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt new file mode 100644 index 0000000..2fe756e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt @@ -0,0 +1,17 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class ConnectingToGitHubState( + val connectionState: ConnectionState = ConnectionState.FETCHING_TOKEN, + val authToken: AuthToken? = null, +) + +enum class ConnectionState { + FETCHING_TOKEN, + SAVING_TOKEN, + SUCCESS, + FAILED, +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt new file mode 100644 index 0000000..2b0a74e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt @@ -0,0 +1,67 @@ +package com.notifier.app.auth.presentation.connecting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.notifier.app.BuildConfig +import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.domain.util.onError +import com.notifier.app.core.domain.util.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ConnectingToGitHubViewModel @Inject constructor( + private val authTokenDataSource: AuthTokenDataSource, +) : ViewModel() { + private val _state = MutableStateFlow(ConnectingToGitHubState()) + val state = _state + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + ConnectingToGitHubState() + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun getAuthToken(code: String?) { + if (code.isNullOrBlank()) { + _state.update { + it.copy( + connectionState = ConnectionState.FAILED + ) + } + return + } + + _state.update { + it.copy( + connectionState = ConnectionState.FETCHING_TOKEN + ) + } + + viewModelScope.launch { + authTokenDataSource.getAuthToken( + clientId = BuildConfig.CLIENT_ID, + clientSecret = BuildConfig.CLIENT_SECRET, + code = code + ).onSuccess { authToken -> + _state.update { + it.copy( + connectionState = ConnectionState.SAVING_TOKEN, + authToken = authToken + ) + } + }.onError { error -> + _events.send(ConnectingToGitHubEvent.Error(error)) + } + } + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt similarity index 62% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt index 57ed033..a762982 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthAction.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login sealed interface AuthAction { data object OnLoginButtonClick : AuthAction diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt similarity index 74% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt index 6c21299..886ea8c 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import com.notifier.app.core.domain.util.NetworkError diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt similarity index 80% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt index 1ee1a5f..c487c4a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.compose.runtime.Immutable import com.notifier.app.auth.domain.AuthToken diff --git a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt similarity index 95% rename from app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt index 2b8909e..a716eb5 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/AuthViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt new file mode 100644 index 0000000..a9f73eb --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -0,0 +1,40 @@ +package com.notifier.app.auth.presentation.login + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.toString +import kotlinx.serialization.Serializable + +@Serializable +data object LoginScreen + +@Composable +fun LoginRoute( + viewModel : AuthViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is AuthEvent.Error -> { + Toast.makeText( + context, + event.error.toString(context), + Toast.LENGTH_LONG + ).show() + } + } + } + + LoginScreen( + state = state, + onAction = { action -> viewModel.onAction(action) } + ) +} + diff --git a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt similarity index 90% rename from app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index 3e6b03d..be55cd3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation +package com.notifier.app.auth.presentation.login import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark -import com.notifier.app.auth.presentation.components.LoginButton +import com.notifier.app.auth.presentation.login.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable diff --git a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt similarity index 93% rename from app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt index 664896a..05f213f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.components +package com.notifier.app.auth.presentation.login.components import androidx.compose.material3.Button import androidx.compose.material3.Text diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt deleted file mode 100644 index 3921109..0000000 --- a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/ConnectingToGitHubScreen.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.notifier.app.core.presentation.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data class ConnectingToGitHubScreen( - val code: String? = null, - val state: String? = null, -) diff --git a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt b/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt deleted file mode 100644 index 9898070..0000000 --- a/app/src/main/java/com/notifier/app/core/presentation/navigation/routes/LoginScreen.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.notifier.app.core.presentation.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data object LoginScreen diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9519b1b..7606708 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ mockk = "1.13.8" ksp = "2.0.21-1.0.27" daggerHilt = "2.56.1" navigation = "2.8.9" +hiltNavigationCompose = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -43,6 +44,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } +hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 2bb8b02d917f8dbd6a189637b5b6193d82f8656d Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:47:52 +0530 Subject: [PATCH 09/44] Rename authentication-related classes and update references to improve clarity and consistency --- .../java/com/notifier/app/MainActivity.kt | 12 +++++----- .../connecting/ConnectingToGitHubEvent.kt | 7 ------ .../connecting/ConnectingToGitHubState.kt | 17 ------------- .../app/auth/presentation/login/AuthAction.kt | 5 ---- .../auth/presentation/login/LoginAction.kt | 5 ++++ .../login/{AuthEvent.kt => LoginEvent.kt} | 4 ++-- .../app/auth/presentation/login/LoginRoute.kt | 4 ++-- .../auth/presentation/login/LoginScreen.kt | 6 ++--- .../login/{AuthState.kt => LoginState.kt} | 2 +- .../{AuthViewModel.kt => LoginViewModel.kt} | 14 +++++------ .../app/auth/presentation/setup/SetupEvent.kt | 7 ++++++ .../SetupRoute.kt} | 12 +++++----- .../SetupScreen.kt} | 22 ++++++++--------- .../app/auth/presentation/setup/SetupState.kt | 24 +++++++++++++++++++ .../SetupViewModel.kt} | 18 +++++++------- 15 files changed, 82 insertions(+), 77 deletions(-) delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthEvent.kt => LoginEvent.kt} (54%) rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthState.kt => LoginState.kt} (90%) rename app/src/main/java/com/notifier/app/auth/presentation/login/{AuthViewModel.kt => LoginViewModel.kt} (68%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubRoute.kt => setup/SetupRoute.kt} (79%) rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubScreen.kt => setup/SetupScreen.kt} (76%) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt rename app/src/main/java/com/notifier/app/auth/presentation/{connecting/ConnectingToGitHubViewModel.kt => setup/SetupViewModel.kt} (75%) diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 03302c9..df4c200 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -14,8 +14,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.toRoute -import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubRoute -import com.notifier.app.auth.presentation.connecting.ConnectingToGitHubScreen +import com.notifier.app.auth.presentation.setup.SetupRoute +import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -48,15 +48,15 @@ private fun MainAppContent() { LoginRoute() } - composable( + composable( deepLinks = listOf( - navDeepLink( + navDeepLink( basePath = "github-notifier://auth-callback" ) ) ) { - val args = it.toRoute() - ConnectingToGitHubRoute(code = args.code) + val args = it.toRoute() + SetupRoute(code = args.code) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt deleted file mode 100644 index b32eb37..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.notifier.app.auth.presentation.connecting - -import com.notifier.app.core.domain.util.NetworkError - -sealed interface ConnectingToGitHubEvent { - data class Error(val error: NetworkError) : ConnectingToGitHubEvent -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt b/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt deleted file mode 100644 index 2fe756e..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubState.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.notifier.app.auth.presentation.connecting - -import androidx.compose.runtime.Immutable -import com.notifier.app.auth.domain.AuthToken - -@Immutable -data class ConnectingToGitHubState( - val connectionState: ConnectionState = ConnectionState.FETCHING_TOKEN, - val authToken: AuthToken? = null, -) - -enum class ConnectionState { - FETCHING_TOKEN, - SAVING_TOKEN, - SUCCESS, - FAILED, -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt deleted file mode 100644 index a762982..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthAction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.notifier.app.auth.presentation.login - -sealed interface AuthAction { - data object OnLoginButtonClick : AuthAction -} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt new file mode 100644 index 0000000..32ac54b --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt @@ -0,0 +1,5 @@ +package com.notifier.app.auth.presentation.login + +sealed interface LoginAction { + data object OnLoginButtonClick : LoginAction +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt similarity index 54% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt index 886ea8c..50af57b 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -2,6 +2,6 @@ package com.notifier.app.auth.presentation.login import com.notifier.app.core.domain.util.NetworkError -sealed interface AuthEvent { - data class Error(val error: NetworkError) : AuthEvent +sealed interface LoginEvent { + data class Error(val error: NetworkError) : LoginEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index a9f73eb..ab64ee3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -15,14 +15,14 @@ data object LoginScreen @Composable fun LoginRoute( - viewModel : AuthViewModel = hiltViewModel() + viewModel : LoginViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is AuthEvent.Error -> { + is LoginEvent.Error -> { Toast.makeText( context, event.error.toString(context), diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index be55cd3..93727a8 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -15,8 +15,8 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun LoginScreen( - state: AuthState, - onAction: (AuthAction) -> Unit, + state: LoginState, + onAction: (LoginAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -35,7 +35,7 @@ private fun LoginScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> LoginScreen( - state = AuthState(), + state = LoginState(), onAction = {}, modifier = Modifier.padding(innerPadding) ) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt similarity index 90% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt index c487c4a..0fc2e8e 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.notifier.app.auth.domain.AuthToken @Immutable -data class AuthState( +data class LoginState( val isLoading: Boolean = false, val authToken: AuthToken? = null, ) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt similarity index 68% rename from app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index a716eb5..6ea57d6 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/AuthViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -12,23 +12,21 @@ import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel -class AuthViewModel @Inject constructor( - private val authTokenDataSource: AuthTokenDataSource, -) : ViewModel() { - private val _state = MutableStateFlow(AuthState()) +class LoginViewModel @Inject constructor() : ViewModel() { + private val _state = MutableStateFlow(LoginState()) val state = _state .stateIn( viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - AuthState() + LoginState() ) - private val _events = Channel() + private val _events = Channel() val events = _events.receiveAsFlow() - fun onAction(action: AuthAction) { + fun onAction(action: LoginAction) { when (action) { - is AuthAction.OnLoginButtonClick -> {} + is LoginAction.OnLoginButtonClick -> {} } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt new file mode 100644 index 0000000..b2327ce --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -0,0 +1,7 @@ +package com.notifier.app.auth.presentation.setup + +import com.notifier.app.core.domain.util.NetworkError + +sealed interface SetupEvent { + data class Error(val error: NetworkError) : SetupEvent +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt similarity index 79% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 696574d..1897434 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import android.widget.Toast import androidx.compose.runtime.Composable @@ -12,15 +12,15 @@ import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable @Serializable -data class ConnectingToGitHubScreen( +data class SetupScreen( val code: String? = null, val state: String? = null, ) @Composable -fun ConnectingToGitHubRoute( +fun SetupRoute( code: String?, - viewModel: ConnectingToGitHubViewModel = hiltViewModel() + viewModel: SetupViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -31,7 +31,7 @@ fun ConnectingToGitHubRoute( ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is ConnectingToGitHubEvent.Error -> { + is SetupEvent.Error -> { Toast.makeText( context, event.error.toString(context), @@ -41,5 +41,5 @@ fun ConnectingToGitHubRoute( } } - ConnectingToGitHubScreen(state = state) + SetupScreen(state = state) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt similarity index 76% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 821f97a..3a068b3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,8 +15,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable -fun ConnectingToGitHubScreen( - state: ConnectingToGitHubState, +fun SetupScreen( + state: SetupState, modifier: Modifier = Modifier, ) { Column( @@ -24,14 +24,14 @@ fun ConnectingToGitHubScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - when (state.connectionState) { - ConnectionState.FETCHING_TOKEN -> { + when (state.setupStep) { + SetupStep.FETCHING_TOKEN -> { Text(text = "Connecting to GitHub...") } - ConnectionState.SAVING_TOKEN -> { + SetupStep.SAVING_TOKEN -> { Text(text = "Saving user information...") } - ConnectionState.SUCCESS -> { + SetupStep.SUCCESS -> { Text(text = "Connected successfully!") Button( onClick = { /* TODO: Navigate to the next screen */ }, @@ -39,7 +39,7 @@ fun ConnectingToGitHubScreen( Text(text = "Continue") } } - ConnectionState.FAILED -> { + SetupStep.FAILED -> { Text(text = "Connection Failed. Please try again.") } } @@ -49,11 +49,11 @@ fun ConnectingToGitHubScreen( @PreviewLightDark @PreviewDynamicColors @Composable -private fun ConnectingToGitHubScreenPreview() { +private fun SetupScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> - ConnectingToGitHubScreen( - state = ConnectingToGitHubState(), + SetupScreen( + state = SetupState(), modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt new file mode 100644 index 0000000..6b12d93 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -0,0 +1,24 @@ +package com.notifier.app.auth.presentation.setup + +import androidx.compose.runtime.Immutable +import com.notifier.app.auth.domain.AuthToken + +@Immutable +data class SetupState( + val setupStep: SetupStep = SetupStep.FETCHING_TOKEN, + val authToken: AuthToken? = null, +) + +enum class SetupStep { + /** Currently retrieving the OAuth token from the backend. */ + FETCHING_TOKEN, + + /** OAuth token has been retrieved, now saving locally. */ + SAVING_TOKEN, + + /** Token saved successfully and setup is complete. */ + SUCCESS, + + /** An error occurred during setup. */ + FAILED +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt similarity index 75% rename from app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt rename to app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index 2b0a74e..fde0011 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/connecting/ConnectingToGitHubViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -1,4 +1,4 @@ -package com.notifier.app.auth.presentation.connecting +package com.notifier.app.auth.presentation.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -17,25 +17,25 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ConnectingToGitHubViewModel @Inject constructor( +class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, ) : ViewModel() { - private val _state = MutableStateFlow(ConnectingToGitHubState()) + private val _state = MutableStateFlow(SetupState()) val state = _state .stateIn( viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - ConnectingToGitHubState() + SetupState() ) - private val _events = Channel() + private val _events = Channel() val events = _events.receiveAsFlow() fun getAuthToken(code: String?) { if (code.isNullOrBlank()) { _state.update { it.copy( - connectionState = ConnectionState.FAILED + setupStep = SetupStep.FAILED ) } return @@ -43,7 +43,7 @@ class ConnectingToGitHubViewModel @Inject constructor( _state.update { it.copy( - connectionState = ConnectionState.FETCHING_TOKEN + setupStep = SetupStep.FETCHING_TOKEN ) } @@ -55,12 +55,12 @@ class ConnectingToGitHubViewModel @Inject constructor( ).onSuccess { authToken -> _state.update { it.copy( - connectionState = ConnectionState.SAVING_TOKEN, + setupStep = SetupStep.SAVING_TOKEN, authToken = authToken ) } }.onError { error -> - _events.send(ConnectingToGitHubEvent.Error(error)) + _events.send(SetupEvent.Error(error)) } } } From b710a5e63b748a20c874f3d7675617504e6f7af5 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Apr 2025 13:49:56 +0530 Subject: [PATCH 10/44] Update SetupStep enum documentation to clarify token retrieval process from GitHub --- .../com/notifier/app/auth/presentation/setup/SetupState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt index 6b12d93..a4faf00 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -10,10 +10,10 @@ data class SetupState( ) enum class SetupStep { - /** Currently retrieving the OAuth token from the backend. */ + /** Currently retrieving the access token from GitHub. */ FETCHING_TOKEN, - /** OAuth token has been retrieved, now saving locally. */ + /** Access token has been retrieved, now saving locally. */ SAVING_TOKEN, /** Token saved successfully and setup is complete. */ From 6e396b232d20c60452d7587e9b2f31a668e36de9 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 11:36:22 +0530 Subject: [PATCH 11/44] Fix daggerHilt variable name --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b6fb8e..f4d99a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,9 +44,9 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } -dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" } -dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" } -dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "dagger-hilt" } +dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } +dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } +dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "daggerHilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } [plugins] From bbb01509d2fe48353cf3f7891e118610e4650b2a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 12:38:28 +0530 Subject: [PATCH 12/44] Refactor error handling in DataStore operations and update related classes for improved clarity --- .../java/com/notifier/app/MainActivity.kt | 4 +- .../app/auth/domain/AuthTokenDataSource.kt | 4 +- .../app/auth/presentation/login/LoginRoute.kt | 2 +- .../auth/presentation/login/LoginViewModel.kt | 1 - .../app/auth/presentation/setup/SetupEvent.kt | 4 +- .../app/auth/presentation/setup/SetupRoute.kt | 16 +++---- .../auth/presentation/setup/SetupScreen.kt | 3 ++ .../auth/presentation/setup/SetupViewModel.kt | 46 +++++++++++++------ .../core/data/persistence/DataStoreManager.kt | 23 ++++------ .../data/persistence/runDataStoreCatching.kt | 37 +++++++++++++++ .../app/core/domain/util/PersistenceError.kt | 7 +++ .../core/presentation/util/ObserveAsEvents.kt | 2 +- .../util/PersistenceErrorToString.kt | 14 ++++++ .../app/core/presentation/util/showToast.kt | 15 ++++++ .../domain/NotificationDataSource.kt | 8 ++-- app/src/main/res/values/strings.xml | 5 +- 16 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt create mode 100644 app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index df4c200..dd9830f 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -14,10 +14,10 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.toRoute -import com.notifier.app.auth.presentation.setup.SetupRoute -import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen +import com.notifier.app.auth.presentation.setup.SetupRoute +import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt index eb69669..f1800c3 100644 --- a/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt +++ b/app/src/main/java/com/notifier/app/auth/domain/AuthTokenDataSource.kt @@ -1,6 +1,6 @@ package com.notifier.app.auth.domain -import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.Result interface AuthTokenDataSource { @@ -8,5 +8,5 @@ interface AuthTokenDataSource { clientId: String, clientSecret: String, code: String, - ): Result + ): Result } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index ab64ee3..5afc90f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -15,7 +15,7 @@ data object LoginScreen @Composable fun LoginRoute( - viewModel : LoginViewModel = hiltViewModel() + viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 6ea57d6..4ce1169 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -2,7 +2,6 @@ package com.notifier.app.auth.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.notifier.app.auth.domain.AuthTokenDataSource import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt index b2327ce..564b867 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -1,7 +1,9 @@ package com.notifier.app.auth.presentation.setup import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.PersistenceError sealed interface SetupEvent { - data class Error(val error: NetworkError) : SetupEvent + data class NetworkErrorEvent(val error: NetworkError) : SetupEvent + data class PersistenceErrorEvent(val error: PersistenceError) : SetupEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 1897434..26f44c3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -1,6 +1,5 @@ package com.notifier.app.auth.presentation.setup -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -8,6 +7,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.notifier.app.core.presentation.util.ObserveAsEvents +import com.notifier.app.core.presentation.util.showToast import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable @@ -20,7 +20,7 @@ data class SetupScreen( @Composable fun SetupRoute( code: String?, - viewModel: SetupViewModel = hiltViewModel() + viewModel: SetupViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -31,12 +31,12 @@ fun SetupRoute( ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is SetupEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() + is SetupEvent.PersistenceErrorEvent -> { + showToast(context, event.error.toString(context)) + } + + is SetupEvent.NetworkErrorEvent -> { + showToast(context, event.error.toString(context)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 3a068b3..0549313 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -28,9 +28,11 @@ fun SetupScreen( SetupStep.FETCHING_TOKEN -> { Text(text = "Connecting to GitHub...") } + SetupStep.SAVING_TOKEN -> { Text(text = "Saving user information...") } + SetupStep.SUCCESS -> { Text(text = "Connected successfully!") Button( @@ -39,6 +41,7 @@ fun SetupScreen( Text(text = "Continue") } } + SetupStep.FAILED -> { Text(text = "Connection Failed. Please try again.") } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index fde0011..00f6c41 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.notifier.app.BuildConfig import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.onError import com.notifier.app.core.domain.util.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,33 +22,31 @@ import javax.inject.Inject @HiltViewModel class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, + private val dataStoreManager: DataStoreManager, ) : ViewModel() { + private val _state = MutableStateFlow(SetupState()) val state = _state .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - SetupState() + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = SetupState() ) private val _events = Channel() val events = _events.receiveAsFlow() + /** + * Initiates the process of exchanging the authorization code for an access token. + * Updates state accordingly and emits error events when necessary. + */ fun getAuthToken(code: String?) { if (code.isNullOrBlank()) { - _state.update { - it.copy( - setupStep = SetupStep.FAILED - ) - } + _state.update { it.copy(setupStep = SetupStep.FAILED) } return } - _state.update { - it.copy( - setupStep = SetupStep.FETCHING_TOKEN - ) - } + _state.update { it.copy(setupStep = SetupStep.FETCHING_TOKEN) } viewModelScope.launch { authTokenDataSource.getAuthToken( @@ -59,8 +60,25 @@ class SetupViewModel @Inject constructor( authToken = authToken ) } + saveAuthToken(authToken.accessToken) + }.onError { error -> + _state.update { it.copy(setupStep = SetupStep.FAILED) } + _events.send(SetupEvent.NetworkErrorEvent(error as NetworkError)) + } + } + } + + /** + * Saves the access token to DataStore and updates setup state. + * Emits an error event if saving fails. + */ + private fun saveAuthToken(token: String) { + viewModelScope.launch { + dataStoreManager.setAccessToken(token).onSuccess { + _state.update { it.copy(setupStep = SetupStep.SUCCESS) } }.onError { error -> - _events.send(SetupEvent.Error(error)) + _state.update { it.copy(setupStep = SetupStep.FAILED) } + _events.send(SetupEvent.PersistenceErrorEvent(error as PersistenceError)) } } } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 224a47b..77749d2 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt @@ -3,6 +3,9 @@ package com.notifier.app.core.data.persistence import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import com.notifier.app.core.domain.util.Error +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -10,8 +13,6 @@ import javax.inject.Singleton /** * Manages access to DataStore for reading and writing app preferences. - * - * @property dataStore the injected instance of DataStore for preferences. */ @Singleton class DataStoreManager @Inject constructor( @@ -21,25 +22,21 @@ class DataStoreManager @Inject constructor( /** * Retrieves the stored access token from DataStore. * - * @return the access token if found, or an empty string if not set or error occurs. + * @return [Result.Success] with the token, or [Result.Error] with a [PersistenceError]. */ - suspend fun getAccessToken(): String { - return try { - dataStore.data - .map { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] ?: "" } - .first() - } catch (e: Exception) { - e.printStackTrace() - "" - } + suspend fun getAccessToken(): Result = runDataStoreCatching { + dataStore.data + .map { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] ?: "" } + .first() } /** * Saves the given access token to DataStore. * * @param accessToken the token to store. + * @return [Result.Success] if stored successfully, or [Result.Error] with a [PersistenceError]. */ - suspend fun setAccessToken(accessToken: String) { + suspend fun setAccessToken(accessToken: String): Result = runDataStoreCatching { dataStore.edit { preferences -> preferences[PreferenceKeys.ACCESS_TOKEN] = accessToken } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt b/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt new file mode 100644 index 0000000..f06cc74 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/data/persistence/runDataStoreCatching.kt @@ -0,0 +1,37 @@ +package com.notifier.app.core.data.persistence + +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result +import kotlinx.coroutines.ensureActive +import kotlinx.io.IOException +import kotlinx.serialization.SerializationException +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext + +/** + * Executes a suspend block safely, returning a [Result] that wraps either the success value + * or a [PersistenceError] in case of exception. + * + * Use this to wrap DataStore operations that might fail (e.g., IO, serialization). + * + * @param block the suspend lambda to execute. + * @return a [Result.Success] if the block executes without exception, + * or a [Result.Error] with appropriate [PersistenceError]. + */ +suspend inline fun runDataStoreCatching( + crossinline block: suspend () -> T, +): Result { + return try { + val result = block() + Result.Success(result) + } catch (e: CancellationException) { + throw e // Let coroutine cancellation propagate + } catch (e: IOException) { + Result.Error(PersistenceError.IO) + } catch (e: SerializationException) { + Result.Error(PersistenceError.SERIALIZATION) + } catch (e: Exception) { + coroutineContext.ensureActive() + Result.Error(PersistenceError.UNKNOWN) + } +} diff --git a/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt b/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt new file mode 100644 index 0000000..23ffd9b --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/domain/util/PersistenceError.kt @@ -0,0 +1,7 @@ +package com.notifier.app.core.domain.util + +enum class PersistenceError : Error { + IO, + SERIALIZATION, + UNKNOWN, +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt index 37ae42f..5053e1d 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ObserveAsEvents.kt @@ -14,7 +14,7 @@ fun ObserveAsEvents( events: Flow, key1: Any? = null, key2: Any? = null, - onEvent: (T) -> Unit + onEvent: (T) -> Unit, ) { val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner.lifecycle, key1, key2) { diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt new file mode 100644 index 0000000..bc3e509 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/PersistenceErrorToString.kt @@ -0,0 +1,14 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import com.notifier.app.R +import com.notifier.app.core.domain.util.PersistenceError + +fun PersistenceError.toString(context: Context): String { + val resId = when (this) { + PersistenceError.IO -> R.string.error_io + PersistenceError.SERIALIZATION -> R.string.error_serialization + PersistenceError.UNKNOWN -> R.string.error_unknown + } + return context.getString(resId) +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt b/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt new file mode 100644 index 0000000..114d53e --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/showToast.kt @@ -0,0 +1,15 @@ +package com.notifier.app.core.presentation.util + +import android.content.Context +import android.widget.Toast + +/** + * Extension function to show a Toast message in the given context. + * + * @param context the context in which the Toast should be shown. + * @param message the message to be shown in the Toast. + * @param duration the duration for which the Toast should be shown. Defaults to Toast.LENGTH_LONG. + */ +fun showToast(context: Context, message: String, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(context, message, duration).show() +} diff --git a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index d1b7f12..d66a59d 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt @@ -1,6 +1,6 @@ package com.notifier.app.notification.domain -import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.Result /** @@ -17,9 +17,9 @@ interface NotificationDataSource { * This function is a suspend function, meaning it should be called from a coroutine * or another suspending function. It returns a [Result] that either contains: * - A **successful** list of [Notification] objects. - * - A **failure** with an appropriate [NetworkError]. + * - A **failure** with an appropriate [Error]. * - * @return A [Result] containing either a list of notifications or a network error. + * @return A [Result] containing either a list of notifications or an error. */ - suspend fun getNotifications(): Result, NetworkError> + suspend fun getNotifications(): Result, Error> } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bccf8e3..f57a1d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ The request timed out. Oops, it seems like your quota is exceeded. Couldn\'t connect to server, please check your internet connection. - Something went wrong. Please try again later. - Couldn\'t serialize or deserialize data. + An unexpected error occurred. Please try again later. + There was an issue with the data format. Please try again later. + Something went wrong while accessing data. Please try again later. From 0d604e8bf8ca6169961165a1f7f44a5acd21425b Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:12:00 +0530 Subject: [PATCH 13/44] Update test script to include verbose output for connectedAndroidTest --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b004171..0ec6afd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,4 +76,5 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true + script: ./gradlew connectedAndroidTest --info --daemon && killall -INT crashpad_handler + From de4f8624e1f6de0911ffcfd35e71922dbe817d05 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:24:33 +0530 Subject: [PATCH 14/44] Enhance test script to handle emulator termination on test failure --- .github/workflows/test.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ec6afd..4baa6fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,5 +76,11 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedAndroidTest --info --daemon && killall -INT crashpad_handler + script: | + ./gradlew connectedAndroidTest --daemon + if [ $? -ne 0 ]; then + echo "Tests failed. Terminating emulator." + killall -INT emulator || true + exit 1 + fi From a34fa01498a5e935dd87bffcecbe8664886e5e60 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:30:37 +0530 Subject: [PATCH 15/44] Revert changes to test.yml --- .github/workflows/test.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4baa6fe..b004171 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,11 +76,4 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: | - ./gradlew connectedAndroidTest --daemon - if [ $? -ne 0 ]; then - echo "Tests failed. Terminating emulator." - killall -INT emulator || true - exit 1 - fi - + script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true From fb5a0cdfd1cc3fb193c6f324b9e509872a638f2c Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 1 May 2025 13:31:04 +0530 Subject: [PATCH 16/44] Refactor DataStoreManager tests to assert Result type and improve clarity --- .../data/persistence/DataStoreManagerTest.kt | 34 ++++++++++++++----- .../core/data/persistence/DataStoreManager.kt | 1 - 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt index f9b22c2..7ed5369 100644 --- a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt +++ b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt @@ -1,6 +1,7 @@ package com.notifier.app.core.data.persistence import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.notifier.app.core.domain.util.Result import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest @@ -28,23 +29,31 @@ class DataStoreManagerTest { @Test fun testGetAccessToken_whenTokenExists_returnsToken() = runTest { val token = "persisted_token" - dataStoreManager.setAccessToken(token) + // Save the token + val result = dataStoreManager.setAccessToken(token) + assert(result is Result.Success) + // Retrieve the token val retrieved = dataStoreManager.getAccessToken() - assertEquals(token, retrieved) + assert(retrieved is Result.Success) + assertEquals(token, (retrieved as Result.Success).data) } @Test fun testGetAccessToken_whenTokenNotSet_returnsEmptyString() = runTest { - val token = dataStoreManager.getAccessToken() - assertEquals("", token) + val result = dataStoreManager.getAccessToken() + assert(result is Result.Success) + assertEquals("", (result as Result.Success).data) } @Test fun testSetAccessToken_withEmptyString_returnsEmptyString() = runTest { - dataStoreManager.setAccessToken("") + val result = dataStoreManager.setAccessToken("") + assert(result is Result.Success) + val token = dataStoreManager.getAccessToken() - assertEquals("", token) + assert(token is Result.Success) + assertEquals("", (token as Result.Success).data) } @Test @@ -52,10 +61,17 @@ class DataStoreManagerTest { val initialToken = "initial_token" val updatedToken = "updated_token" - dataStoreManager.setAccessToken(initialToken) - dataStoreManager.setAccessToken(updatedToken) + // Set initial token + var result = dataStoreManager.setAccessToken(initialToken) + assert(result is Result.Success) + + // Set updated token + result = dataStoreManager.setAccessToken(updatedToken) + assert(result is Result.Success) + // Retrieve the updated token val retrievedToken = dataStoreManager.getAccessToken() - assertEquals(updatedToken, retrievedToken) + assert(retrievedToken is Result.Success) + assertEquals(updatedToken, (retrievedToken as Result.Success).data) } } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 77749d2..7a34207 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt @@ -18,7 +18,6 @@ import javax.inject.Singleton class DataStoreManager @Inject constructor( private val dataStore: DataStore, ) { - /** * Retrieves the stored access token from DataStore. * From a52d591f3fd7eb1951f581d6bc3a51c71e111560 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 2 May 2025 23:08:36 +0530 Subject: [PATCH 17/44] Add splash screen support and update app theme --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- app/src/main/java/com/notifier/app/MainActivity.kt | 4 ++++ app/src/main/res/values/splash.xml | 6 ++++++ gradle/libs.versions.toml | 2 ++ 5 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/values/splash.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aff72b2..652f438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.dagger.hilt) implementation(libs.hilt.navigation.compose) + implementation(libs.androidx.splash.screen) ksp(libs.dagger.hilt.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f1665b..8f685c1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" - android:theme="@style/Theme.GitHubNotifier" + android:theme="@style/Theme.App.Starting" tools:targetApi="31"> + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4d99a1..f8ccda1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ daggerHilt = "2.56.1" navigation = "2.8.9" hiltNavigationCompose = "1.2.0" datastore = "1.1.4" +splashScreen = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -48,6 +49,7 @@ dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "daggerHilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 3e586c41183bf2f6a732591578ad2d92a31fd35e Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 2 May 2025 23:25:23 +0530 Subject: [PATCH 18/44] Refactor splash screen implementation and set post-splash theme --- app/src/main/java/com/notifier/app/MainActivity.kt | 4 +--- app/src/main/res/values/splash.xml | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 96b4642..b03a3cc 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -26,9 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - installSplashScreen().apply { - actionBar?.hide() - } + installSplashScreen() enableEdgeToEdge() setContent { GitHubNotifierTheme { diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml index 32fac70..5c46377 100644 --- a/app/src/main/res/values/splash.xml +++ b/app/src/main/res/values/splash.xml @@ -2,5 +2,6 @@ From d15d412cf4835c1f1fca2d3c6ce6fb38be88bad5 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 2 May 2025 23:25:45 +0530 Subject: [PATCH 19/44] Inject HttpClientFactory into provideHttpClient and retrieve access token from DataStoreManager --- .../core/data/networking/HttpClientFactory.kt | 23 +++++++++++++++---- .../java/com/notifier/app/di/ApiModule.kt | 6 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt index cd6a215..31469f1 100644 --- a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt +++ b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt @@ -1,5 +1,7 @@ package com.notifier.app.core.data.networking +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.onSuccess import io.ktor.client.HttpClient import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.HttpTimeout @@ -14,12 +16,18 @@ import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.http.headers import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton /** - * Factory object for creating an instance of [HttpClient] with predefined configurations. + * Factory for creating an instance of [HttpClient] with predefined configurations. */ -object HttpClientFactory { +@Singleton +class HttpClientFactory @Inject constructor( + private val dataStoreManager: DataStoreManager +) { /** * Creates and configures an instance of [HttpClient] with logging, JSON serialization, * and default request headers. @@ -55,9 +63,16 @@ object HttpClientFactory { contentType(ContentType.Application.Json) } - // TODO: Inject the token once dagger-hilt is setup. + val accessToken = runBlocking { + var retrievedToken = "" + dataStoreManager.getAccessToken().onSuccess { + retrievedToken = it + } + retrievedToken + } + headers { - append(HttpHeaders.Authorization, "Bearer ") + append(HttpHeaders.Authorization, "Bearer $accessToken") append("X-GitHub-Api-Version", "2022-11-28") } } diff --git a/app/src/main/java/com/notifier/app/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index ba92f5b..d12eed6 100644 --- a/app/src/main/java/com/notifier/app/di/ApiModule.kt +++ b/app/src/main/java/com/notifier/app/di/ApiModule.kt @@ -16,8 +16,10 @@ import javax.inject.Singleton object ApiModule { @Provides @Singleton - fun provideHttpClient(): HttpClient { - return HttpClientFactory.create(CIO.create()) + fun provideHttpClient( + httpClientFactory: HttpClientFactory + ): HttpClient { + return httpClientFactory.create(CIO.create()) } @Provides From 3dae00195959da316ef61017fed5a3183dc0cb18 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 2 May 2025 23:35:03 +0530 Subject: [PATCH 20/44] Enhance login flow with navigation events and update LoginButton to accept onClick parameter --- .../java/com/notifier/app/MainActivity.kt | 24 ++++++++++- .../auth/presentation/login/LoginAction.kt | 1 + .../app/auth/presentation/login/LoginEvent.kt | 5 +-- .../app/auth/presentation/login/LoginRoute.kt | 16 +++---- .../auth/presentation/login/LoginScreen.kt | 21 +++++++++- .../app/auth/presentation/login/LoginState.kt | 10 +++-- .../auth/presentation/login/LoginViewModel.kt | 42 ++++++++++++++++++- .../login/components/LoginButton.kt | 9 ++-- .../auth/presentation/setup/SetupAction.kt | 5 +++ .../app/auth/presentation/setup/SetupEvent.kt | 1 + .../app/auth/presentation/setup/SetupRoute.kt | 10 ++++- .../auth/presentation/setup/SetupScreen.kt | 4 +- .../auth/presentation/setup/SetupViewModel.kt | 10 +++++ .../core/data/networking/HttpClientFactory.kt | 2 +- .../java/com/notifier/app/di/ApiModule.kt | 2 +- .../app/notification/presentation/.gitkeep | 0 .../presentation/NotificationRoute.kt | 13 ++++++ .../presentation/NotificationScreen.kt | 40 ++++++++++++++++++ 18 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt delete mode 100644 app/src/main/java/com/notifier/app/notification/presentation/.gitkeep create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index b03a3cc..f24de85 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -19,6 +19,8 @@ import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.auth.presentation.setup.SetupRoute import com.notifier.app.auth.presentation.setup.SetupScreen +import com.notifier.app.notification.presentation.NotificationRoute +import com.notifier.app.notification.presentation.NotificationScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -47,7 +49,15 @@ private fun MainAppContent() { modifier = Modifier.padding(innerPadding) ) { composable { - LoginRoute() + LoginRoute( + onNavigateToHomeScreen = { + navController.navigate(NotificationScreen) { + popUpTo(LoginScreen) { + inclusive = true + } + } + } + ) } composable( @@ -58,7 +68,17 @@ private fun MainAppContent() { ) ) { val args = it.toRoute() - SetupRoute(code = args.code) + SetupRoute(code = args.code, onNavigateToHomeScreen = { + navController.navigate(NotificationScreen) { + popUpTo(LoginScreen) { + inclusive = true + } + } + }) + } + + composable { + NotificationRoute() } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt index 32ac54b..ed39219 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt @@ -2,4 +2,5 @@ package com.notifier.app.auth.presentation.login sealed interface LoginAction { data object OnLoginButtonClick : LoginAction + data object OnUserLoggedIn : LoginAction } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt index 50af57b..a737f36 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -1,7 +1,6 @@ package com.notifier.app.auth.presentation.login -import com.notifier.app.core.domain.util.NetworkError - sealed interface LoginEvent { - data class Error(val error: NetworkError) : LoginEvent + data object NavigateToHomeScreen : LoginEvent + data object NavigateToGitHubAuth : LoginEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index 5afc90f..aac6eec 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -1,13 +1,12 @@ package com.notifier.app.auth.presentation.login -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.core.presentation.util.ObserveAsEvents -import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable @Serializable @@ -15,6 +14,7 @@ data object LoginScreen @Composable fun LoginRoute( + onNavigateToHomeScreen: () -> Unit, viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -22,12 +22,12 @@ fun LoginRoute( ObserveAsEvents(events = viewModel.events) { event -> when (event) { - is LoginEvent.Error -> { - Toast.makeText( - context, - event.error.toString(context), - Toast.LENGTH_LONG - ).show() + LoginEvent.NavigateToHomeScreen -> { + onNavigateToHomeScreen() + } + + LoginEvent.NavigateToGitHubAuth -> { + context.startActivity(createGitHubAuthIntent()) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index 93727a8..aca8edb 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -4,7 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,7 +26,24 @@ fun LoginScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - LoginButton() + when (state.status) { + LoginStatus.LOADING -> { + CircularProgressIndicator() + Text(text = "Verifying authentication status...") + } + + LoginStatus.LOGGED_IN -> { + onAction(LoginAction.OnUserLoggedIn) + } + + LoginStatus.LOGGED_OUT -> { + LoginButton( + onClick = { + onAction(LoginAction.OnLoginButtonClick) + } + ) + } + } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt index 0fc2e8e..b60a485 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -1,10 +1,14 @@ package com.notifier.app.auth.presentation.login import androidx.compose.runtime.Immutable -import com.notifier.app.auth.domain.AuthToken @Immutable data class LoginState( - val isLoading: Boolean = false, - val authToken: AuthToken? = null, + val status: LoginStatus = LoginStatus.LOADING, ) + +enum class LoginStatus { + LOADING, + LOGGED_IN, + LOGGED_OUT, +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 4ce1169..3872100 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -1,19 +1,29 @@ package com.notifier.app.auth.presentation.login +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.onError +import com.notifier.app.core.domain.util.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class LoginViewModel @Inject constructor() : ViewModel() { +class LoginViewModel @Inject constructor( + private val dataStoreManager: DataStoreManager, +) : ViewModel() { private val _state = MutableStateFlow(LoginState()) val state = _state + .onStart { checkAuthStatus() } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), @@ -25,7 +35,35 @@ class LoginViewModel @Inject constructor() : ViewModel() { fun onAction(action: LoginAction) { when (action) { - is LoginAction.OnLoginButtonClick -> {} + is LoginAction.OnLoginButtonClick -> { + viewModelScope.launch { + _events.send(LoginEvent.NavigateToGitHubAuth) + } + } + + LoginAction.OnUserLoggedIn -> { + viewModelScope.launch { + _events.send(LoginEvent.NavigateToHomeScreen) + } + } + } + } + + private fun checkAuthStatus() { + viewModelScope.launch { + dataStoreManager.getAccessToken().onSuccess { accessToken -> + _state.update { + it.copy( + status = if (accessToken.isBlank()) + LoginStatus.LOGGED_OUT + else + LoginStatus.LOGGED_IN + ) + } + }.onError { error -> + _state.update { it.copy(status = LoginStatus.LOGGED_OUT) } + Log.d("LoginViewModel", "Error fetching access token: $error") + } } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt index 05f213f..bcaa5f9 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -7,17 +7,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark -import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable -fun LoginButton(modifier: Modifier = Modifier) { +fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { val context = LocalContext.current Button( modifier = modifier, - onClick = { - context.startActivity(createGitHubAuthIntent()) - }, + onClick = onClick, ) { Text("Login with GitHub") } @@ -28,6 +25,6 @@ fun LoginButton(modifier: Modifier = Modifier) { @Composable private fun LoginButtonPreview() { GitHubNotifierTheme { - LoginButton() + LoginButton(onClick = {}) } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt new file mode 100644 index 0000000..e39c295 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt @@ -0,0 +1,5 @@ +package com.notifier.app.auth.presentation.setup + +sealed interface SetupAction { + data object OnContinueButtonClick : SetupAction +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt index 564b867..c70af35 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -6,4 +6,5 @@ import com.notifier.app.core.domain.util.PersistenceError sealed interface SetupEvent { data class NetworkErrorEvent(val error: NetworkError) : SetupEvent data class PersistenceErrorEvent(val error: PersistenceError) : SetupEvent + data object NavigateToHomeScreen : SetupEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 26f44c3..928e468 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -20,6 +20,7 @@ data class SetupScreen( @Composable fun SetupRoute( code: String?, + onNavigateToHomeScreen: () -> Unit, viewModel: SetupViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -38,8 +39,15 @@ fun SetupRoute( is SetupEvent.NetworkErrorEvent -> { showToast(context, event.error.toString(context)) } + + SetupEvent.NavigateToHomeScreen -> { + onNavigateToHomeScreen() + } } } - SetupScreen(state = state) + SetupScreen( + state = state, + onAction = { action -> viewModel.onAction(action) } + ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 0549313..2edea1d 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -17,6 +17,7 @@ import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun SetupScreen( state: SetupState, + onAction: (SetupAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -36,7 +37,7 @@ fun SetupScreen( SetupStep.SUCCESS -> { Text(text = "Connected successfully!") Button( - onClick = { /* TODO: Navigate to the next screen */ }, + onClick = { onAction(SetupAction.OnContinueButtonClick) }, ) { Text(text = "Continue") } @@ -57,6 +58,7 @@ private fun SetupScreenPreview() { Scaffold { innerPadding -> SetupScreen( state = SetupState(), + onAction = {}, modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index 00f6c41..e2aff61 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -36,6 +36,16 @@ class SetupViewModel @Inject constructor( private val _events = Channel() val events = _events.receiveAsFlow() + fun onAction(action: SetupAction) { + when (action) { + is SetupAction.OnContinueButtonClick -> { + viewModelScope.launch { + _events.send(SetupEvent.NavigateToHomeScreen) + } + } + } + } + /** * Initiates the process of exchanging the authorization code for an access token. * Updates state accordingly and emits error events when necessary. diff --git a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt index 31469f1..046dd1f 100644 --- a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt +++ b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt @@ -26,7 +26,7 @@ import javax.inject.Singleton */ @Singleton class HttpClientFactory @Inject constructor( - private val dataStoreManager: DataStoreManager + private val dataStoreManager: DataStoreManager, ) { /** * Creates and configures an instance of [HttpClient] with logging, JSON serialization, diff --git a/app/src/main/java/com/notifier/app/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index d12eed6..74d1010 100644 --- a/app/src/main/java/com/notifier/app/di/ApiModule.kt +++ b/app/src/main/java/com/notifier/app/di/ApiModule.kt @@ -17,7 +17,7 @@ object ApiModule { @Provides @Singleton fun provideHttpClient( - httpClientFactory: HttpClientFactory + httpClientFactory: HttpClientFactory, ): HttpClient { return httpClientFactory.create(CIO.create()) } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/.gitkeep b/app/src/main/java/com/notifier/app/notification/presentation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt new file mode 100644 index 0000000..d8e33b3 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt @@ -0,0 +1,13 @@ +package com.notifier.app.notification.presentation + +import androidx.compose.runtime.Composable +import kotlinx.serialization.Serializable + +@Serializable +data object NotificationScreen + +@Composable +fun NotificationRoute() { + NotificationScreen() +} + diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt new file mode 100644 index 0000000..f589281 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -0,0 +1,40 @@ +package com.notifier.app.notification.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun NotificationScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "Notification Screen") + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun NotificationScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + NotificationScreen( + modifier = Modifier.padding(innerPadding) + ) + } + } +} From 62fe4ede3b76a298926fa14e87a90b51e47c3ec4 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 7 May 2025 20:56:02 +0530 Subject: [PATCH 21/44] Implement GitHub OAuth flow with state management and DataStore integration --- .../java/com/notifier/app/MainActivity.kt | 85 +++++++----- .../app/auth/presentation/login/LoginRoute.kt | 6 +- .../app/auth/presentation/setup/SetupRoute.kt | 3 +- .../auth/presentation/setup/SetupViewModel.kt | 123 +++++++++++++----- .../util/GitHubAuthIntentProvider.kt | 35 +++++ .../util/createGitHubAuthIntent.kt | 22 ---- .../core/data/networking/HttpClientFactory.kt | 2 +- .../core/data/persistence/DataStoreManager.kt | 34 +++++ .../core/data/persistence/PreferenceKeys.kt | 3 + 9 files changed, 220 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt delete mode 100644 app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index f24de85..2a181c6 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -19,13 +20,18 @@ import com.notifier.app.auth.presentation.login.LoginRoute import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.auth.presentation.setup.SetupRoute import com.notifier.app.auth.presentation.setup.SetupScreen +import com.notifier.app.auth.presentation.util.GitHubAuthIntentProvider import com.notifier.app.notification.presentation.NotificationRoute import com.notifier.app.notification.presentation.NotificationScreen import com.notifier.app.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var gitHubAuthIntentProvider: GitHubAuthIntentProvider + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) installSplashScreen() @@ -36,49 +42,58 @@ class MainActivity : ComponentActivity() { } } } -} -@Composable -private fun MainAppContent() { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - val navController = rememberNavController() + @Composable + private fun MainAppContent() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val navController = rememberNavController() + val context = LocalContext.current - NavHost( - navController = navController, - startDestination = LoginScreen, - modifier = Modifier.padding(innerPadding) - ) { - composable { - LoginRoute( - onNavigateToHomeScreen = { - navController.navigate(NotificationScreen) { - popUpTo(LoginScreen) { - inclusive = true + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + LoginRoute( + onNavigateToHomeScreen = { + navController.navigate(NotificationScreen) { + popUpTo(LoginScreen) { + inclusive = true + } } + }, + onNavigateToGitHubAuth = { + val authIntentPair = gitHubAuthIntentProvider.createGitHubAuthIntent() + context.startActivity(authIntentPair.first) } - } - ) - } + ) + } - composable( - deepLinks = listOf( - navDeepLink( - basePath = "github-notifier://auth-callback" + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) ) - ) - ) { - val args = it.toRoute() - SetupRoute(code = args.code, onNavigateToHomeScreen = { - navController.navigate(NotificationScreen) { - popUpTo(LoginScreen) { - inclusive = true + ) { + val args = it.toRoute() + SetupRoute( + code = args.code, + receivedState = args.state, + onNavigateToHomeScreen = { + navController.navigate(NotificationScreen) { + popUpTo(LoginScreen) { + inclusive = true + } + } } - } - }) - } + ) + } - composable { - NotificationRoute() + composable { + NotificationRoute() + } } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index aac6eec..2b99bf1 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.notifier.app.auth.presentation.util.createGitHubAuthIntent import com.notifier.app.core.presentation.util.ObserveAsEvents import kotlinx.serialization.Serializable @@ -15,7 +14,8 @@ data object LoginScreen @Composable fun LoginRoute( onNavigateToHomeScreen: () -> Unit, - viewModel: LoginViewModel = hiltViewModel(), + onNavigateToGitHubAuth: () -> Unit, + viewModel: LoginViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -27,7 +27,7 @@ fun LoginRoute( } LoginEvent.NavigateToGitHubAuth -> { - context.startActivity(createGitHubAuthIntent()) + onNavigateToGitHubAuth() } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 928e468..679c4d6 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -20,6 +20,7 @@ data class SetupScreen( @Composable fun SetupRoute( code: String?, + receivedState: String?, onNavigateToHomeScreen: () -> Unit, viewModel: SetupViewModel = hiltViewModel(), ) { @@ -27,7 +28,7 @@ fun SetupRoute( val context = LocalContext.current LaunchedEffect(code) { - viewModel.getAuthToken(code) + viewModel.getAuthToken(code, receivedState) } ObserveAsEvents(events = viewModel.events) { event -> diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index e2aff61..d532993 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.notifier.app.BuildConfig import com.notifier.app.auth.domain.AuthTokenDataSource import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.onError @@ -26,70 +27,130 @@ class SetupViewModel @Inject constructor( ) : ViewModel() { private val _state = MutableStateFlow(SetupState()) - val state = _state - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - initialValue = SetupState() - ) + val state = _state.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = SetupState() + ) private val _events = Channel() val events = _events.receiveAsFlow() fun onAction(action: SetupAction) { when (action) { - is SetupAction.OnContinueButtonClick -> { - viewModelScope.launch { - _events.send(SetupEvent.NavigateToHomeScreen) - } - } + is SetupAction.OnContinueButtonClick -> navigateToHome() } } /** - * Initiates the process of exchanging the authorization code for an access token. - * Updates state accordingly and emits error events when necessary. + * Handles the OAuth redirect by verifying state and requesting the access token. */ - fun getAuthToken(code: String?) { - if (code.isNullOrBlank()) { - _state.update { it.copy(setupStep = SetupStep.FAILED) } + fun getAuthToken(code: String?, receivedState: String?) { + if (code.isNullOrBlank() || receivedState.isNullOrBlank()) { + failSetup() return } - _state.update { it.copy(setupStep = SetupStep.FETCHING_TOKEN) } - viewModelScope.launch { + val isStateValid = validateOAuthState(receivedState) + if (!isStateValid) return@launch + + updateSetupStep(SetupStep.FETCHING_TOKEN) + authTokenDataSource.getAuthToken( clientId = BuildConfig.CLIENT_ID, clientSecret = BuildConfig.CLIENT_SECRET, code = code ).onSuccess { authToken -> - _state.update { - it.copy( - setupStep = SetupStep.SAVING_TOKEN, - authToken = authToken - ) - } + updateSetupStep(SetupStep.SAVING_TOKEN) + _state.update { it.copy(authToken = authToken) } saveAuthToken(authToken.accessToken) }.onError { error -> - _state.update { it.copy(setupStep = SetupStep.FAILED) } - _events.send(SetupEvent.NetworkErrorEvent(error as NetworkError)) + handleNetworkError(error) } } } /** - * Saves the access token to DataStore and updates setup state. - * Emits an error event if saving fails. + * Validates the received OAuth state against the saved state. + */ + private suspend fun validateOAuthState(receivedState: String): Boolean { + var savedState = "" + dataStoreManager.getOAuthState() + .onSuccess { savedState = it } + .onError { + failSetup() + return false + } + + if (receivedState != savedState) { + failSetup() + return false + } + + dataStoreManager.clearOAuthState() + .onError { + failSetup() + return false + } + + return true + } + + /** + * Saves the access token and updates setup state. */ private fun saveAuthToken(token: String) { viewModelScope.launch { dataStoreManager.setAccessToken(token).onSuccess { - _state.update { it.copy(setupStep = SetupStep.SUCCESS) } + updateSetupStep(SetupStep.SUCCESS) }.onError { error -> - _state.update { it.copy(setupStep = SetupStep.FAILED) } - _events.send(SetupEvent.PersistenceErrorEvent(error as PersistenceError)) + failSetup() + handlePersistenceError(error) } } } + + /** + * Navigates to the home screen. + */ + private fun navigateToHome() { + viewModelScope.launch { + _events.send(SetupEvent.NavigateToHomeScreen) + } + } + + /** + * Updates the current setup step. + */ + private fun updateSetupStep(step: SetupStep) { + _state.update { it.copy(setupStep = step) } + } + + /** + * Sets state to failed and stops the setup process. + */ + private fun failSetup() { + updateSetupStep(SetupStep.FAILED) + } + + /** + * Emits a network error event. + */ + private suspend fun handleNetworkError(error: Error) { + failSetup() + _events.send( + SetupEvent.NetworkErrorEvent( + if (error is NetworkError) error else NetworkError.UNKNOWN + ) + ) + } + + /** + * Emits a persistence error event. + */ + private suspend fun handlePersistenceError(error: Error) { + val actualError = if (error is PersistenceError) error else PersistenceError.UNKNOWN + _events.send(SetupEvent.PersistenceErrorEvent(actualError)) + } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt new file mode 100644 index 0000000..62510c7 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt @@ -0,0 +1,35 @@ +package com.notifier.app.auth.presentation.util + +import android.content.Intent +import androidx.core.net.toUri +import com.notifier.app.BuildConfig +import com.notifier.app.core.data.persistence.DataStoreManager +import kotlinx.coroutines.runBlocking +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private const val GITHUB_AUTH_BASE_URL = "https://github.com/login/oauth/authorize" + +@Singleton +class GitHubAuthIntentProvider @Inject constructor( + private val dataStoreManager: DataStoreManager +) { + /** + * Creates an [Intent] to launch the GitHub OAuth authorization page in a browser. + * + * @return [Intent] configured with the GitHub OAuth URL and required parameters. + */ + fun createGitHubAuthIntent(): Pair { + val clientId = BuildConfig.CLIENT_ID + val state = UUID.randomUUID().toString() + + runBlocking { + dataStoreManager.setOAuthState(state) + } + + val authUrl = "$GITHUB_AUTH_BASE_URL?client_id=$clientId&state=$state" + + return Pair(Intent(Intent.ACTION_VIEW, authUrl.toUri()), state) + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt deleted file mode 100644 index 8205579..0000000 --- a/app/src/main/java/com/notifier/app/auth/presentation/util/createGitHubAuthIntent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.notifier.app.auth.presentation.util - -import android.content.Intent -import androidx.core.net.toUri -import com.notifier.app.BuildConfig -import java.util.UUID - -private const val GITHUB_AUTH_BASE_URL = "https://github.com/login/oauth/authorize" - -/** - * Creates an [Intent] to launch the GitHub OAuth authorization page in a browser. - * - * @return [Intent] configured with the GitHub OAuth URL and required parameters. - */ -fun createGitHubAuthIntent(): Intent { - val clientId = BuildConfig.CLIENT_ID - val state = UUID.randomUUID().toString() - - val authUrl = "$GITHUB_AUTH_BASE_URL?client_id=$clientId&state=$state" - - return Intent(Intent.ACTION_VIEW, authUrl.toUri()) -} diff --git a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt index 046dd1f..fca9b92 100644 --- a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt +++ b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt @@ -68,7 +68,7 @@ class HttpClientFactory @Inject constructor( dataStoreManager.getAccessToken().onSuccess { retrievedToken = it } - retrievedToken + return@runBlocking retrievedToken } headers { diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 7a34207..3024731 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt @@ -40,4 +40,38 @@ class DataStoreManager @Inject constructor( preferences[PreferenceKeys.ACCESS_TOKEN] = accessToken } } + + /** + * Clears the stored access token from DataStore. + * + * @return [Result.Success] if cleared successfully, or [Result.Error] with a [PersistenceError]. + */ + suspend fun clearAccessToken(): Result = runDataStoreCatching { + dataStore.edit { preferences -> + preferences.remove(PreferenceKeys.ACCESS_TOKEN) + } + } + + suspend fun getOAuthState(): Result = runDataStoreCatching { + dataStore.data + .map { preferences -> preferences[PreferenceKeys.OAUTH_STATE] ?: "" } + .first() + } + + suspend fun setOAuthState(state: String): Result = runDataStoreCatching { + dataStore.edit { preferences -> + preferences[PreferenceKeys.OAUTH_STATE] = state + } + } + + /** + * Clears the stored OAuth state from DataStore. + * + * @return [Result.Success] if cleared successfully, or [Result.Error] with a [PersistenceError]. + */ + suspend fun clearOAuthState(): Result = runDataStoreCatching { + dataStore.edit { preferences -> + preferences.remove(PreferenceKeys.OAUTH_STATE) + } + } } diff --git a/app/src/main/java/com/notifier/app/core/data/persistence/PreferenceKeys.kt b/app/src/main/java/com/notifier/app/core/data/persistence/PreferenceKeys.kt index e4647f6..0fed12c 100644 --- a/app/src/main/java/com/notifier/app/core/data/persistence/PreferenceKeys.kt +++ b/app/src/main/java/com/notifier/app/core/data/persistence/PreferenceKeys.kt @@ -8,4 +8,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey object PreferenceKeys { /** Key for storing the user's access token. */ val ACCESS_TOKEN = stringPreferencesKey("access_token") + + /** Key for storing the user's oauth state. */ + val OAUTH_STATE = stringPreferencesKey("oauth_state") } From bff5863da688494ef06e097c39d13561ced004ef Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 8 May 2025 13:38:45 +0530 Subject: [PATCH 22/44] Refactor login flow to enhance state management and navigation events --- .../auth/presentation/login/LoginAction.kt | 15 +++ .../app/auth/presentation/login/LoginEvent.kt | 16 +++ .../app/auth/presentation/login/LoginRoute.kt | 40 +++++-- .../auth/presentation/login/LoginScreen.kt | 63 ++++++++--- .../app/auth/presentation/login/LoginState.kt | 21 +++- .../auth/presentation/login/LoginViewModel.kt | 105 +++++++++++++----- .../login/components/LoginButton.kt | 11 +- .../util/GitHubAuthIntentProvider.kt | 2 +- 8 files changed, 215 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt index ed39219..ab13a97 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt @@ -1,6 +1,21 @@ package com.notifier.app.auth.presentation.login +/** + * A sealed interface representing different actions related to the login process. + * + * These actions are used to trigger changes in the state of the login flow based on user + * interactions or other events. + */ sealed interface LoginAction { + /** + * Action triggered when the login button is clicked. + * This action will initiate the login process. + */ data object OnLoginButtonClick : LoginAction + + /** + * Action triggered when the user is successfully logged in. + * This action will mark the login process as completed. + */ data object OnUserLoggedIn : LoginAction } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt index a737f36..0ca875d 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -1,6 +1,22 @@ package com.notifier.app.auth.presentation.login +/** + * A sealed interface representing different login-related events. + * + * These events are used to trigger UI navigation actions in response to user interactions or + * state changes. + */ sealed interface LoginEvent { + /** + * Event that triggers navigation to the home screen. + * This event is fired when the user successfully logs in and should be redirected to the + * home screen. + */ data object NavigateToHomeScreen : LoginEvent + + /** + * Event that triggers navigation to the GitHub authentication screen. + * This event is fired when the user presses the login button to authenticate via GitHub. + */ data object NavigateToGitHubAuth : LoginEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt index 2b99bf1..f727b41 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -1,40 +1,58 @@ package com.notifier.app.auth.presentation.login import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.notifier.app.core.presentation.util.ObserveAsEvents import kotlinx.serialization.Serializable +/** + * Navigation route object for the Login screen. + * + * This is used for identifying and serializing the Login screen destination. + * It is used in conjunction with navigation libraries that require serializable objects. + */ @Serializable data object LoginScreen +/** + * The LoginRoute Composable is the entry point to the Login screen. + * + * It observes the login state from the [LoginViewModel], listens for navigation events, + * and renders the appropriate UI. Based on the current login status, the view model is triggered + * to navigate to different screens, such as the GitHub authentication or home screen. + * + * @param onNavigateToHomeScreen A callback invoked when the user is successfully logged in. + * @param onNavigateToGitHubAuth A callback invoked when the user needs to authenticate via GitHub. + * @param viewModel The instance of the [LoginViewModel] used for managing login state and actions. + */ @Composable fun LoginRoute( onNavigateToHomeScreen: () -> Unit, onNavigateToGitHubAuth: () -> Unit, - viewModel: LoginViewModel = hiltViewModel() + viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current ObserveAsEvents(events = viewModel.events) { event -> when (event) { - LoginEvent.NavigateToHomeScreen -> { - onNavigateToHomeScreen() - } + LoginEvent.NavigateToHomeScreen -> onNavigateToHomeScreen() + LoginEvent.NavigateToGitHubAuth -> onNavigateToGitHubAuth() + } + } - LoginEvent.NavigateToGitHubAuth -> { - onNavigateToGitHubAuth() - } + // Triggered when the login status changes to LOGGED_IN, initiating navigation actions + LaunchedEffect(state.status) { + if (state.status == LoginStatus.LOGGED_IN) { + // The user has logged in, so we trigger the OnUserLoggedIn action + viewModel.onAction(LoginAction.OnUserLoggedIn) } } LoginScreen( state = state, - onAction = { action -> viewModel.onAction(action) } + onLoginButtonClick = { viewModel.onAction(LoginAction.OnLoginButtonClick) } ) } - diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index aca8edb..00d229a 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -2,7 +2,9 @@ package com.notifier.app.auth.presentation.login import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold @@ -12,13 +14,28 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import com.notifier.app.auth.presentation.login.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme +/** + * A composable function that displays the login screen UI. + * + * This function renders different views based on the login status: + * - **Loading**: Displays a circular progress indicator and a status message. + * - **Logged Out**: Displays a login button. + * - **Logged In**: Displays a success message. + * + * @param state The current login state that dictates what UI is shown. + * @param onLoginButtonClick The action to take when the login button is clicked. + * @param modifier An optional modifier to be applied to the root layout. + */ @Composable fun LoginScreen( state: LoginState, - onAction: (LoginAction) -> Unit, + onLoginButtonClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -27,35 +44,53 @@ fun LoginScreen( horizontalAlignment = Alignment.CenterHorizontally ) { when (state.status) { - LoginStatus.LOADING -> { + LoginStatus.LOADING, null -> { CircularProgressIndicator() - Text(text = "Verifying authentication status...") + Spacer(modifier = Modifier.height(16.dp)) + Text("Verifying authentication status...") } - LoginStatus.LOGGED_IN -> { - onAction(LoginAction.OnUserLoggedIn) + LoginStatus.LOGGED_OUT -> { + LoginButton(onClick = onLoginButtonClick) } - LoginStatus.LOGGED_OUT -> { - LoginButton( - onClick = { - onAction(LoginAction.OnLoginButtonClick) - } - ) + LoginStatus.LOGGED_IN -> { + Text("Logged in successfully! Redirecting...") } } } } +/** + * Preview parameter provider for displaying different login states in previews. + * + * Provides sample values for the LoginState to simulate different UI scenarios. + */ +class LoginStateParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + LoginState(status = LoginStatus.LOADING), + LoginState(status = LoginStatus.LOGGED_OUT), + LoginState(status = LoginStatus.LOGGED_IN) + ) +} + +/** + * Preview of the [LoginScreen] composable with dynamic colors and support for light/dark themes. + * + * This preview allows visualization of the login screen in various states (loading, logged out, logged in). + */ @PreviewLightDark @PreviewDynamicColors @Composable -private fun LoginScreenPreview() { +private fun LoginScreenPreview( + @PreviewParameter(LoginStateParameterProvider::class) state: LoginState, +) { GitHubNotifierTheme { Scaffold { innerPadding -> LoginScreen( - state = LoginState(), - onAction = {}, + state = state, + onLoginButtonClick = {}, modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt index b60a485..d9b2111 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -2,13 +2,32 @@ package com.notifier.app.auth.presentation.login import androidx.compose.runtime.Immutable +/** + * UI state for the login screen. + * + * Represents the current login status displayed to the user. + * + * @property status The [LoginStatus] representing whether the user is: + * - Loading authentication check + * - Logged in + * - Logged out + * Can be null initially before the status is determined. + */ @Immutable data class LoginState( - val status: LoginStatus = LoginStatus.LOADING, + val status: LoginStatus? = null, ) +/** + * Enum representing the possible authentication statuses for the login screen. + */ enum class LoginStatus { + /** Indicates authentication status is being checked. */ LOADING, + + /** Indicates user is authenticated and logged in. */ LOGGED_IN, + + /** Indicates user is not authenticated and needs to log in. */ LOGGED_OUT, } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 3872100..1447968 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -10,6 +10,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -17,53 +18,99 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * ViewModel for managing the login screen's state and events. + * + * This ViewModel handles: + * - Checking if the user is already authenticated + * - Managing login UI state (loading, logged in, logged out) + * - Sending navigation events to the UI + * + * @property dataStoreManager Used to retrieve the stored access token for authentication check. + */ @HiltViewModel class LoginViewModel @Inject constructor( private val dataStoreManager: DataStoreManager, ) : ViewModel() { + companion object { + /** Tag used for logging errors from this ViewModel. */ + private const val TAG = "LoginViewModel" + } + + // Internal mutable state holding the current login UI state private val _state = MutableStateFlow(LoginState()) - val state = _state - .onStart { checkAuthStatus() } + + /** + * Public immutable state exposed to UI. + * + * - Triggers [checkAuthStatus] when the flow starts collecting (via [onStart]) + * - Emits the current [LoginState] to the UI + */ + val state: StateFlow = _state + .onStart { + // Mark the UI as loading while checking authentication status + _state.update { it.copy(status = LoginStatus.LOADING) } + checkAuthStatus() + } .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - LoginState() + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = LoginState() // Initial state before any flow emissions ) - private val _events = Channel() + // Internal channel for emitting one-time UI events (e.g., navigation) + private val _events = Channel(Channel.BUFFERED) + + /** + * Public flow of one-time events consumed by the UI (e.g., navigation triggers). + */ val events = _events.receiveAsFlow() + /** + * Handles user actions from the UI. + * + * @param action the user-triggered action + */ fun onAction(action: LoginAction) { when (action) { - is LoginAction.OnLoginButtonClick -> { - viewModelScope.launch { - _events.send(LoginEvent.NavigateToGitHubAuth) - } - } - - LoginAction.OnUserLoggedIn -> { - viewModelScope.launch { - _events.send(LoginEvent.NavigateToHomeScreen) - } - } + LoginAction.OnLoginButtonClick -> sendEvent(LoginEvent.NavigateToGitHubAuth) + LoginAction.OnUserLoggedIn -> sendEvent(LoginEvent.NavigateToHomeScreen) } } + /** + * Checks if the user is already authenticated by retrieving the saved access token. + * + * Updates [_state] based on whether a valid token is found: + * - [LoginStatus.LOGGED_IN] if a non-blank token exists + * - [LoginStatus.LOGGED_OUT] otherwise + * + * If an error occurs, logs the error and treats user as logged out. + */ private fun checkAuthStatus() { viewModelScope.launch { - dataStoreManager.getAccessToken().onSuccess { accessToken -> - _state.update { - it.copy( - status = if (accessToken.isBlank()) - LoginStatus.LOGGED_OUT - else - LoginStatus.LOGGED_IN - ) + dataStoreManager.getAccessToken() + .onSuccess { accessToken -> + _state.update { + it.copy( + status = if (accessToken.isBlank()) LoginStatus.LOGGED_OUT + else LoginStatus.LOGGED_IN + ) + } + } + .onError { error -> + Log.e(TAG, "Error fetching access token: $error") + _state.update { it.copy(status = LoginStatus.LOGGED_OUT) } } - }.onError { error -> - _state.update { it.copy(status = LoginStatus.LOGGED_OUT) } - Log.d("LoginViewModel", "Error fetching access token: $error") - } } } + + /** + * Sends a one-time event to the UI. + * + * @param event the event to send (e.g., navigation trigger) + */ + private fun sendEvent(event: LoginEvent) { + viewModelScope.launch { _events.send(event) } + } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt index bcaa5f9..3ef6d22 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -4,14 +4,18 @@ import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import com.notifier.app.ui.theme.GitHubNotifierTheme +/** + * A composable button that triggers the GitHub login flow when clicked. + * + * @param onClick Lambda function to handle the button click event. + * @param modifier Modifier to be applied to the button, allowing customization of its layout. + */ @Composable fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - val context = LocalContext.current Button( modifier = modifier, onClick = onClick, @@ -20,6 +24,9 @@ fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { } } +/** + * Preview function for the LoginButton composable in both light and dark modes with dynamic colors. + */ @PreviewLightDark @PreviewDynamicColors @Composable diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt index 62510c7..c523a1b 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt @@ -13,7 +13,7 @@ private const val GITHUB_AUTH_BASE_URL = "https://github.com/login/oauth/authori @Singleton class GitHubAuthIntentProvider @Inject constructor( - private val dataStoreManager: DataStoreManager + private val dataStoreManager: DataStoreManager, ) { /** * Creates an [Intent] to launch the GitHub OAuth authorization page in a browser. From 6afc242c67e7db957255eeb4679ab0d924f827b6 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 11 May 2025 22:41:16 +0530 Subject: [PATCH 23/44] Enhance setup flow with detailed documentation for events and actions --- .../app/auth/presentation/login/LoginEvent.kt | 2 + .../auth/presentation/setup/SetupAction.kt | 10 +++ .../app/auth/presentation/setup/SetupEvent.kt | 28 +++++++++ .../app/auth/presentation/setup/SetupRoute.kt | 27 +++++++- .../auth/presentation/setup/SetupScreen.kt | 45 +++++++++++++- .../app/auth/presentation/setup/SetupState.kt | 17 +++++- .../auth/presentation/setup/SetupViewModel.kt | 61 +++++++++++++++++-- 7 files changed, 176 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt index 0ca875d..ccb3b5e 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -9,6 +9,7 @@ package com.notifier.app.auth.presentation.login sealed interface LoginEvent { /** * Event that triggers navigation to the home screen. + * * This event is fired when the user successfully logs in and should be redirected to the * home screen. */ @@ -16,6 +17,7 @@ sealed interface LoginEvent { /** * Event that triggers navigation to the GitHub authentication screen. + * * This event is fired when the user presses the login button to authenticate via GitHub. */ data object NavigateToGitHubAuth : LoginEvent diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt index e39c295..289e469 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt @@ -1,5 +1,15 @@ package com.notifier.app.auth.presentation.setup +/** + * A sealed interface representing different user actions in the Setup screen. + * + * These actions are dispatched based on user interactions during the setup process. + */ sealed interface SetupAction { + /** + * Action triggered when the user clicks the "Continue" button + * after a successful setup. + * This action will proceed the user to the next screen. + */ data object OnContinueButtonClick : SetupAction } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt index c70af35..d1d2b85 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -3,8 +3,36 @@ package com.notifier.app.auth.presentation.setup import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.PersistenceError +/** + * A sealed interface representing different setup-related events. + * + * These events are used to trigger UI actions or navigation in response to state changes + * during the setup process. + */ sealed interface SetupEvent { + /** + * Event that triggers a toast or error message for a network-related error. + * + * This event is fired when a network error occurs while fetching or validating data. + * + * @param error The network error that occurred. + */ data class NetworkErrorEvent(val error: NetworkError) : SetupEvent + + /** + * Event that triggers a toast or error message for a persistence-related error. + * + * This event is fired when an error occurs while saving data locally. + * + * @param error The persistence error that occurred. + */ data class PersistenceErrorEvent(val error: PersistenceError) : SetupEvent + + /** + * Event that triggers navigation to the home screen. + * + * This event is fired after the setup process completes successfully and the user + * should be redirected to the home screen. + */ data object NavigateToHomeScreen : SetupEvent } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index 679c4d6..e1ed86d 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -11,12 +11,34 @@ import com.notifier.app.core.presentation.util.showToast import com.notifier.app.core.presentation.util.toString import kotlinx.serialization.Serializable +/** + * Navigation route object for the Setup screen. + * + * This data class holds parameters required for the Setup screen to function, + * such as the GitHub authorization code and state. + * It is used for deep linking and navigation within the app. + * + * @property code The authorization code returned by GitHub after successful login. + * @property state A value to protect against CSRF attacks and validate the response. + */ @Serializable data class SetupScreen( val code: String? = null, val state: String? = null, ) +/** + * The SetupRoute Composable is the entry point to the Setup screen. + * + * It initializes the access token retrieval process using the provided authorization code, + * observes the setup state from the [SetupViewModel], handles side effects such as error toasts and + * navigation, and renders the Setup screen. + * + * @param code The authorization code received from GitHub. + * @param receivedState The state parameter received from GitHub for CSRF protection. + * @param onNavigateToHomeScreen A callback invoked when setup completes successfully. + * @param viewModel The instance of the [SetupViewModel] used for managing setup logic and state. + */ @Composable fun SetupRoute( code: String?, @@ -27,26 +49,27 @@ fun SetupRoute( val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current + // Starts token retrieval when the authorization code is available LaunchedEffect(code) { viewModel.getAuthToken(code, receivedState) } + // Observes events and performs UI side effects such as showing toasts or navigating ObserveAsEvents(events = viewModel.events) { event -> when (event) { is SetupEvent.PersistenceErrorEvent -> { showToast(context, event.error.toString(context)) } - is SetupEvent.NetworkErrorEvent -> { showToast(context, event.error.toString(context)) } - SetupEvent.NavigateToHomeScreen -> { onNavigateToHomeScreen() } } } + // Renders the Setup screen with the current UI state SetupScreen( state = state, onAction = { action -> viewModel.onAction(action) } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 2edea1d..43ebf44 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -12,8 +12,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.notifier.app.ui.theme.GitHubNotifierTheme +/** + * A composable function that displays the GitHub setup screen UI. + * + * Based on the current [SetupState], this screen provides visual feedback to the user during + * different stages of GitHub token setup: + * - **FETCHING_TOKEN**: Indicates that the app is connecting to GitHub. + * - **SAVING_TOKEN**: Indicates that the token is being stored locally. + * - **SUCCESS**: Indicates a successful connection and provides a Continue button. + * - **FAILED**: Indicates that the setup failed and instructs the user to retry. + * + * @param state The current setup state that determines which UI is shown. + * @param onAction A callback triggered when the user interacts with the screen (e.g., clicking Continue). + * @param modifier An optional [Modifier] to be applied to the root layout. + */ @Composable fun SetupScreen( state: SetupState, @@ -44,23 +60,46 @@ fun SetupScreen( } SetupStep.FAILED -> { - Text(text = "Connection Failed. Please try again.") + Text(text = "Connection failed. Please try again.") } } } } +/** + * Preview parameter provider for displaying different setup states in previews. + * + * Provides sample values for the [SetupState] to simulate each setup step (fetching, saving, success, failed). + */ +class SetupStateParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SetupState(setupStep = SetupStep.FETCHING_TOKEN), + SetupState(setupStep = SetupStep.SAVING_TOKEN), + SetupState(setupStep = SetupStep.SUCCESS), + SetupState(setupStep = SetupStep.FAILED) + ) +} + +/** + * Preview of the [SetupScreen] composable with dynamic colors and light/dark theme support. + * + * This preview helps visualize how the Setup screen looks in different visual states. + */ @PreviewLightDark @PreviewDynamicColors @Composable -private fun SetupScreenPreview() { +private fun SetupScreenPreview( + @PreviewParameter(SetupStateParameterProvider::class) state: SetupState, +) { GitHubNotifierTheme { Scaffold { innerPadding -> SetupScreen( - state = SetupState(), + state = state, onAction = {}, modifier = Modifier.padding(innerPadding) ) } } } + diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt index a4faf00..b542664 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -3,22 +3,33 @@ package com.notifier.app.auth.presentation.setup import androidx.compose.runtime.Immutable import com.notifier.app.auth.domain.AuthToken +/** + * UI state for the setup screen. + * + * Represents the current status of the initial setup process, such as fetching and saving the access token. + * + * @property setupStep The current step in the setup process, as represented by [SetupStep]. + * @property authToken The [AuthToken] retrieved during setup. Can be null if not yet fetched or if setup failed. + */ @Immutable data class SetupState( val setupStep: SetupStep = SetupStep.FETCHING_TOKEN, val authToken: AuthToken? = null, ) +/** + * Enum representing the different stages of the setup process. + */ enum class SetupStep { /** Currently retrieving the access token from GitHub. */ FETCHING_TOKEN, - /** Access token has been retrieved, now saving locally. */ + /** Access token has been retrieved and is now being saved locally. */ SAVING_TOKEN, - /** Token saved successfully and setup is complete. */ + /** Token has been saved successfully and setup is complete. */ SUCCESS, - /** An error occurred during setup. */ + /** An error occurred during the setup process. */ FAILED } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index d532993..c163e50 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -20,22 +20,50 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * ViewModel for managing the setup screen's state and events. + * + * This ViewModel handles: + * - Managing OAuth authentication flow (state validation, token retrieval, token persistence) + * - Managing setup UI state (loading, success, failed) + * - Sending navigation and error events to the UI + * + * @property authTokenDataSource Used to exchange the OAuth code for an access token. + * @property dataStoreManager Used to store and retrieve OAuth-related data locally. + */ @HiltViewModel class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, private val dataStoreManager: DataStoreManager, ) : ViewModel() { + // Internal mutable state holding the current setup UI state private val _state = MutableStateFlow(SetupState()) + + /** + * Public immutable state exposed to UI. + * + * Emits the current [SetupState] to the UI, which reflects progress through setup steps. + */ val state = _state.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), initialValue = SetupState() ) + // Internal channel for emitting one-time UI events (e.g., navigation, error messages) private val _events = Channel() + + /** + * Public flow of one-time events consumed by the UI (e.g., navigation triggers, error toasts). + */ val events = _events.receiveAsFlow() + /** + * Handles user actions from the UI. + * + * @param action the user-triggered action + */ fun onAction(action: SetupAction) { when (action) { is SetupAction.OnContinueButtonClick -> navigateToHome() @@ -43,7 +71,13 @@ class SetupViewModel @Inject constructor( } /** - * Handles the OAuth redirect by verifying state and requesting the access token. + * Handles the OAuth redirect by validating state and exchanging the authorization code + * for an access token. + * + * If validation or token exchange fails, updates [_state] and emits error events. + * + * @param code the authorization code from OAuth callback + * @param receivedState the OAuth state parameter from OAuth callback */ fun getAuthToken(code: String?, receivedState: String?) { if (code.isNullOrBlank() || receivedState.isNullOrBlank()) { @@ -73,6 +107,9 @@ class SetupViewModel @Inject constructor( /** * Validates the received OAuth state against the saved state. + * + * @param receivedState the OAuth state received from OAuth callback + * @return true if state is valid; false otherwise (also triggers setup failure) */ private suspend fun validateOAuthState(receivedState: String): Boolean { var savedState = "" @@ -99,6 +136,10 @@ class SetupViewModel @Inject constructor( /** * Saves the access token and updates setup state. + * + * Emits a [SetupEvent.PersistenceErrorEvent] if saving fails. + * + * @param token the OAuth access token to save */ private fun saveAuthToken(token: String) { viewModelScope.launch { @@ -112,7 +153,7 @@ class SetupViewModel @Inject constructor( } /** - * Navigates to the home screen. + * Sends an event to navigate to the home screen. */ private fun navigateToHome() { viewModelScope.launch { @@ -121,21 +162,27 @@ class SetupViewModel @Inject constructor( } /** - * Updates the current setup step. + * Updates the current setup step in [_state]. + * + * @param step the new setup step */ private fun updateSetupStep(step: SetupStep) { _state.update { it.copy(setupStep = step) } } /** - * Sets state to failed and stops the setup process. + * Marks the setup process as failed. + * + * Updates [_state] and prevents further progress. */ private fun failSetup() { updateSetupStep(SetupStep.FAILED) } /** - * Emits a network error event. + * Emits a [SetupEvent.NetworkErrorEvent] and marks setup as failed. + * + * @param error the network error that occurred */ private suspend fun handleNetworkError(error: Error) { failSetup() @@ -147,7 +194,9 @@ class SetupViewModel @Inject constructor( } /** - * Emits a persistence error event. + * Emits a [SetupEvent.PersistenceErrorEvent] when a persistence error occurs. + * + * @param error the persistence error that occurred */ private suspend fun handlePersistenceError(error: Error) { val actualError = if (error is PersistenceError) error else PersistenceError.UNKNOWN From 70d8aa1914c261e8ced9f23cc2c24ec7b55a770a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 22 May 2025 20:48:14 +0530 Subject: [PATCH 24/44] Use Truth assertions and move HttpClientFactoryTest to AndroidTest --- app/build.gradle.kts | 5 ++-- .../data/networking/HttpClientFactoryTest.kt | 22 +++++++++++++++- .../data/persistence/DataStoreManagerTest.kt | 26 +++++++++---------- gradle/libs.versions.toml | 4 +-- 4 files changed, 39 insertions(+), 18 deletions(-) rename app/src/{test => androidTest}/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt (77%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 652f438..6702070 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,20 +94,21 @@ dependencies { implementation(libs.dagger.hilt) implementation(libs.hilt.navigation.compose) implementation(libs.androidx.splash.screen) + implementation(libs.androidx.junit.ktx) ksp(libs.dagger.hilt.compiler) testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.ktor.client.mock) testImplementation(libs.mockk) - androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.truth) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.dagger.hilt.testing) + androidTestImplementation(libs.ktor.client.mock) kspAndroidTest(libs.dagger.hilt.compiler) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/test/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt b/app/src/androidTest/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt similarity index 77% rename from app/src/test/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt rename to app/src/androidTest/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt index a8c570e..3345516 100644 --- a/app/src/test/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt +++ b/app/src/androidTest/java/com/notifier/app/core/data/networking/HttpClientFactoryTest.kt @@ -1,6 +1,9 @@ package com.notifier.app.core.data.networking +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -11,9 +14,26 @@ import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.plugin import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) class HttpClientFactoryTest { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var httpClientFactory: HttpClientFactory + + @Before + fun setUp() { + hiltRule.inject() + } + @Test fun testHttpClientFactory_createsHttpClientSuccessfully() { val client = createMockClient() @@ -44,7 +64,7 @@ class HttpClientFactoryTest { headers = headersOf("Content-Type", "application/json") ) } - return HttpClientFactory.create(engine) + return httpClientFactory.create(engine) } private fun isPluginInstalled(plugin: ClientPlugin): Boolean { diff --git a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt index 7ed5369..a1cfd2b 100644 --- a/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt +++ b/app/src/androidTest/java/com/notifier/app/core/data/persistence/DataStoreManagerTest.kt @@ -1,11 +1,11 @@ package com.notifier.app.core.data.persistence import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.notifier.app.core.domain.util.Result import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test @@ -31,29 +31,29 @@ class DataStoreManagerTest { val token = "persisted_token" // Save the token val result = dataStoreManager.setAccessToken(token) - assert(result is Result.Success) + assertThat(result).isInstanceOf(Result.Success::class.java) // Retrieve the token val retrieved = dataStoreManager.getAccessToken() - assert(retrieved is Result.Success) - assertEquals(token, (retrieved as Result.Success).data) + assertThat(retrieved).isInstanceOf(Result.Success::class.java) + assertThat((retrieved as Result.Success).data).isEqualTo(token) } @Test fun testGetAccessToken_whenTokenNotSet_returnsEmptyString() = runTest { val result = dataStoreManager.getAccessToken() - assert(result is Result.Success) - assertEquals("", (result as Result.Success).data) + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat((result as Result.Success).data).isEqualTo("") } @Test fun testSetAccessToken_withEmptyString_returnsEmptyString() = runTest { val result = dataStoreManager.setAccessToken("") - assert(result is Result.Success) + assertThat(result).isInstanceOf(Result.Success::class.java) val token = dataStoreManager.getAccessToken() - assert(token is Result.Success) - assertEquals("", (token as Result.Success).data) + assertThat(token).isInstanceOf(Result.Success::class.java) + assertThat((token as Result.Success).data).isEqualTo("") } @Test @@ -63,15 +63,15 @@ class DataStoreManagerTest { // Set initial token var result = dataStoreManager.setAccessToken(initialToken) - assert(result is Result.Success) + assertThat(result).isInstanceOf(Result.Success::class.java) // Set updated token result = dataStoreManager.setAccessToken(updatedToken) - assert(result is Result.Success) + assertThat(result).isInstanceOf(Result.Success::class.java) // Retrieve the updated token val retrievedToken = dataStoreManager.getAccessToken() - assert(retrievedToken is Result.Success) - assertEquals(updatedToken, (retrievedToken as Result.Success).data) + assertThat(retrievedToken).isInstanceOf(Result.Success::class.java) + assertThat((retrievedToken as Result.Success).data).isEqualTo(updatedToken) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8ccda1..24cc989 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ agp = "8.9.1" kotlin = "2.1.10" coreKtx = "1.15.0" junit = "4.13.2" -junitVersion = "1.2.1" +junitKtx = "1.2.1" espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.10.1" @@ -22,7 +22,7 @@ splashScreen = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From ef3ad69e4eb259d757ac105b96d3706c1c7c932e Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 22 May 2025 21:28:07 +0530 Subject: [PATCH 25/44] Add Ktor client mock dependency for enhanced testing capabilities --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6702070..d34e674 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.mock) testImplementation(libs.mockk) androidTestImplementation(libs.truth) From 3d1671340b66315b4543d682a43e2e14169ae830 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 30 May 2025 18:05:40 +0530 Subject: [PATCH 26/44] Implement BaseViewModel to streamline state management and event handling in ViewModels --- .../auth/presentation/login/LoginViewModel.kt | 39 ++++------------ .../auth/presentation/setup/SetupViewModel.kt | 45 ++++++------------- .../app/core/presentation/BaseViewModel.kt | 34 ++++++++++++++ 3 files changed, 56 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 1447968..76b9a54 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -1,18 +1,15 @@ package com.notifier.app.auth.presentation.login import android.util.Log -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.notifier.app.core.data.persistence.DataStoreManager import com.notifier.app.core.domain.util.onError import com.notifier.app.core.domain.util.onSuccess +import com.notifier.app.core.presentation.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,25 +28,22 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val dataStoreManager: DataStoreManager, -) : ViewModel() { +) : BaseViewModel(LoginState()) { companion object { /** Tag used for logging errors from this ViewModel. */ private const val TAG = "LoginViewModel" } - // Internal mutable state holding the current login UI state - private val _state = MutableStateFlow(LoginState()) - /** * Public immutable state exposed to UI. * * - Triggers [checkAuthStatus] when the flow starts collecting (via [onStart]) * - Emits the current [LoginState] to the UI */ - val state: StateFlow = _state + override val state: StateFlow = mutableStateFlow .onStart { // Mark the UI as loading while checking authentication status - _state.update { it.copy(status = LoginStatus.LOADING) } + mutableStateFlow.update { it.copy(status = LoginStatus.LOADING) } checkAuthStatus() } .stateIn( @@ -58,20 +52,12 @@ class LoginViewModel @Inject constructor( initialValue = LoginState() // Initial state before any flow emissions ) - // Internal channel for emitting one-time UI events (e.g., navigation) - private val _events = Channel(Channel.BUFFERED) - - /** - * Public flow of one-time events consumed by the UI (e.g., navigation triggers). - */ - val events = _events.receiveAsFlow() - /** * Handles user actions from the UI. * * @param action the user-triggered action */ - fun onAction(action: LoginAction) { + override fun onAction(action: LoginAction) { when (action) { LoginAction.OnLoginButtonClick -> sendEvent(LoginEvent.NavigateToGitHubAuth) LoginAction.OnUserLoggedIn -> sendEvent(LoginEvent.NavigateToHomeScreen) @@ -81,7 +67,7 @@ class LoginViewModel @Inject constructor( /** * Checks if the user is already authenticated by retrieving the saved access token. * - * Updates [_state] based on whether a valid token is found: + * Updates [mutableStateFlow] based on whether a valid token is found: * - [LoginStatus.LOGGED_IN] if a non-blank token exists * - [LoginStatus.LOGGED_OUT] otherwise * @@ -91,7 +77,7 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { dataStoreManager.getAccessToken() .onSuccess { accessToken -> - _state.update { + mutableStateFlow.update { it.copy( status = if (accessToken.isBlank()) LoginStatus.LOGGED_OUT else LoginStatus.LOGGED_IN @@ -100,17 +86,8 @@ class LoginViewModel @Inject constructor( } .onError { error -> Log.e(TAG, "Error fetching access token: $error") - _state.update { it.copy(status = LoginStatus.LOGGED_OUT) } + mutableStateFlow.update { it.copy(status = LoginStatus.LOGGED_OUT) } } } } - - /** - * Sends a one-time event to the UI. - * - * @param event the event to send (e.g., navigation trigger) - */ - private fun sendEvent(event: LoginEvent) { - viewModelScope.launch { _events.send(event) } - } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index c163e50..1e9fe7c 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -1,6 +1,5 @@ package com.notifier.app.auth.presentation.setup -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.notifier.app.BuildConfig import com.notifier.app.auth.domain.AuthTokenDataSource @@ -10,11 +9,9 @@ import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.onError import com.notifier.app.core.domain.util.onSuccess +import com.notifier.app.core.presentation.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,36 +32,24 @@ import javax.inject.Inject class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, private val dataStoreManager: DataStoreManager, -) : ViewModel() { - - // Internal mutable state holding the current setup UI state - private val _state = MutableStateFlow(SetupState()) - +) : BaseViewModel(SetupState()) { /** * Public immutable state exposed to UI. * * Emits the current [SetupState] to the UI, which reflects progress through setup steps. */ - val state = _state.stateIn( + override val state = mutableStateFlow.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), initialValue = SetupState() ) - // Internal channel for emitting one-time UI events (e.g., navigation, error messages) - private val _events = Channel() - - /** - * Public flow of one-time events consumed by the UI (e.g., navigation triggers, error toasts). - */ - val events = _events.receiveAsFlow() - /** * Handles user actions from the UI. * * @param action the user-triggered action */ - fun onAction(action: SetupAction) { + override fun onAction(action: SetupAction) { when (action) { is SetupAction.OnContinueButtonClick -> navigateToHome() } @@ -74,7 +59,7 @@ class SetupViewModel @Inject constructor( * Handles the OAuth redirect by validating state and exchanging the authorization code * for an access token. * - * If validation or token exchange fails, updates [_state] and emits error events. + * If validation or token exchange fails, updates [mutableStateFlow] and emits error events. * * @param code the authorization code from OAuth callback * @param receivedState the OAuth state parameter from OAuth callback @@ -97,7 +82,7 @@ class SetupViewModel @Inject constructor( code = code ).onSuccess { authToken -> updateSetupStep(SetupStep.SAVING_TOKEN) - _state.update { it.copy(authToken = authToken) } + mutableStateFlow.update { it.copy(authToken = authToken) } saveAuthToken(authToken.accessToken) }.onError { error -> handleNetworkError(error) @@ -156,24 +141,22 @@ class SetupViewModel @Inject constructor( * Sends an event to navigate to the home screen. */ private fun navigateToHome() { - viewModelScope.launch { - _events.send(SetupEvent.NavigateToHomeScreen) - } + sendEvent(SetupEvent.NavigateToHomeScreen) } /** - * Updates the current setup step in [_state]. + * Updates the current setup step in [mutableStateFlow]. * * @param step the new setup step */ private fun updateSetupStep(step: SetupStep) { - _state.update { it.copy(setupStep = step) } + mutableStateFlow.update { it.copy(setupStep = step) } } /** * Marks the setup process as failed. * - * Updates [_state] and prevents further progress. + * Updates [mutableStateFlow] and prevents further progress. */ private fun failSetup() { updateSetupStep(SetupStep.FAILED) @@ -184,9 +167,9 @@ class SetupViewModel @Inject constructor( * * @param error the network error that occurred */ - private suspend fun handleNetworkError(error: Error) { + private fun handleNetworkError(error: Error) { failSetup() - _events.send( + sendEvent( SetupEvent.NetworkErrorEvent( if (error is NetworkError) error else NetworkError.UNKNOWN ) @@ -198,8 +181,8 @@ class SetupViewModel @Inject constructor( * * @param error the persistence error that occurred */ - private suspend fun handlePersistenceError(error: Error) { + private fun handlePersistenceError(error: Error) { val actualError = if (error is PersistenceError) error else PersistenceError.UNKNOWN - _events.send(SetupEvent.PersistenceErrorEvent(actualError)) + sendEvent(SetupEvent.PersistenceErrorEvent(actualError)) } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt new file mode 100644 index 0000000..79d84a5 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt @@ -0,0 +1,34 @@ +package com.notifier.app.core.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +abstract class BaseViewModel( + private val initialState: State, +) : ViewModel() { + protected val mutableStateFlow = MutableStateFlow(initialState) + open val state: StateFlow + get() = mutableStateFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = initialState // Initial state before any flow emissions + ) + + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + abstract fun onAction(action: Action) + + protected fun sendEvent(event: Event) { + viewModelScope.launch { _events.send(event) } + } +} From cd217c81e01196be9901f5a70ae4ad063dc14aeb Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 30 May 2025 18:06:06 +0530 Subject: [PATCH 27/44] Add test tags to CircularProgressIndicator and implement LoginScreen tests for loading and login states --- .../presentation/login/LoginScreenTest.kt | 65 +++++++++++++++++++ .../auth/presentation/login/LoginScreen.kt | 5 +- .../app/auth/presentation/setup/SetupRoute.kt | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt new file mode 100644 index 0000000..62a7dc8 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt @@ -0,0 +1,65 @@ +package com.notifier.app.auth.presentation.login + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class LoginScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun testLoginScreen_whenLoading_showsProgressAndMessage() { + composeRule.setContent { + LoginScreen( + state = LoginState(status = LoginStatus.LOADING), + onLoginButtonClick = {} + ) + } + + composeRule + .onNode(hasTestTag("CircularProgressIndicator")) + .assertExists() + composeRule + .onNodeWithText("Verifying authentication status...") + .assertIsDisplayed() + } + + @Test + fun testLoginScreen_whenLoggedOut_showsLoginButton_clickTriggersCallback() { + var loginButtonClicked = false + + composeRule.setContent { + LoginScreen( + state = LoginState(status = LoginStatus.LOGGED_OUT), + onLoginButtonClick = { loginButtonClicked = true } + ) + } + + composeRule + .onNodeWithText("Login with GitHub") + .assertIsDisplayed() + .performClick() + + assertThat(loginButtonClicked).isTrue() + } + + @Test + fun testLoginScreen_whenLoggedIn_showsSuccessMessage() { + composeRule.setContent { + LoginScreen( + state = LoginState(status = LoginStatus.LOGGED_IN), + onLoginButtonClick = {} + ) + } + + composeRule + .onNodeWithText("Logged in successfully! Redirecting...") + .assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index 00d229a..5862f56 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter @@ -45,7 +46,9 @@ fun LoginScreen( ) { when (state.status) { LoginStatus.LOADING, null -> { - CircularProgressIndicator() + CircularProgressIndicator( + modifier = Modifier.testTag("CircularProgressIndicator"), + ) Spacer(modifier = Modifier.height(16.dp)) Text("Verifying authentication status...") } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt index e1ed86d..118fa8f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -60,9 +60,11 @@ fun SetupRoute( is SetupEvent.PersistenceErrorEvent -> { showToast(context, event.error.toString(context)) } + is SetupEvent.NetworkErrorEvent -> { showToast(context, event.error.toString(context)) } + SetupEvent.NavigateToHomeScreen -> { onNavigateToHomeScreen() } From eaf8dc1c9e97314172d2d159d0e9e41e5e48bd47 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 30 May 2025 18:07:58 +0530 Subject: [PATCH 28/44] Refactor test tag for CircularProgressIndicator in LoginScreen for consistency in tests --- .../notifier/app/auth/presentation/login/LoginScreenTest.kt | 2 +- .../com/notifier/app/auth/presentation/login/LoginScreen.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt index 62a7dc8..6d54f68 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt @@ -23,7 +23,7 @@ class LoginScreenTest { } composeRule - .onNode(hasTestTag("CircularProgressIndicator")) + .onNode(hasTestTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR)) .assertExists() composeRule .onNodeWithText("Verifying authentication status...") diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index 5862f56..f75f756 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.unit.dp import com.notifier.app.auth.presentation.login.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme +const val TEST_TAG_CIRCULAR_PROGRESS_INDICATOR = "TestTag.CircularProgressIndicator" + /** * A composable function that displays the login screen UI. * @@ -47,7 +49,7 @@ fun LoginScreen( when (state.status) { LoginStatus.LOADING, null -> { CircularProgressIndicator( - modifier = Modifier.testTag("CircularProgressIndicator"), + modifier = Modifier.testTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR), ) Spacer(modifier = Modifier.height(16.dp)) Text("Verifying authentication status...") From 85b58b6e54dc82604b3f759da4d32cc5bcb8f8ad Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 30 May 2025 18:17:31 +0530 Subject: [PATCH 29/44] Add unit tests for SetupScreen to verify UI states and interactions --- .../presentation/setup/SetupScreenTest.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt new file mode 100644 index 0000000..5306432 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt @@ -0,0 +1,83 @@ +package com.notifier.app.auth.presentation.setup + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class SetupScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun testSetupScreen_whenFetchingToken_showsConnectingMessage() { + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.FETCHING_TOKEN), + onAction = {} + ) + } + + composeRule + .onNodeWithText("Connecting to GitHub...") + .assertIsDisplayed() + } + + @Test + fun testSetupScreen_whenSavingToken_showsSavingMessage() { + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.SAVING_TOKEN), + onAction = {} + ) + } + + composeRule + .onNodeWithText("Saving user information...") + .assertIsDisplayed() + } + + @Test + fun testSetupScreen_whenSuccess_showsSuccessMessageAndContinueButton_clickTriggersCallback() { + var continueClicked = false + + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.SUCCESS), + onAction = { + if (it == SetupAction.OnContinueButtonClick) { + continueClicked = true + } + } + ) + } + + composeRule + .onNodeWithText("Connected successfully!") + .assertIsDisplayed() + + composeRule + .onNodeWithText("Continue") + .assertIsDisplayed() + .performClick() + + assertThat(continueClicked).isTrue() + } + + @Test + fun testSetupScreen_whenFailed_showsErrorMessage() { + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.FAILED), + onAction = {} + ) + } + + composeRule + .onNodeWithText("Connection failed. Please try again.") + .assertIsDisplayed() + } +} From 204c1c2db30609cf42812f9c8a9b390cd95284b3 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 31 May 2025 07:16:48 +0530 Subject: [PATCH 30/44] Add string resources for login and setup screen messages --- .../app/auth/presentation/login/LoginScreen.kt | 6 ++++-- .../presentation/login/components/LoginButton.kt | 4 +++- .../app/auth/presentation/setup/SetupScreen.kt | 12 +++++++----- app/src/main/res/values/strings.xml | 8 ++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt index f75f756..146840c 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import com.notifier.app.R import com.notifier.app.auth.presentation.login.components.LoginButton import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -52,7 +54,7 @@ fun LoginScreen( modifier = Modifier.testTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR), ) Spacer(modifier = Modifier.height(16.dp)) - Text("Verifying authentication status...") + Text(stringResource(R.string.logging_in_loading_message)) } LoginStatus.LOGGED_OUT -> { @@ -60,7 +62,7 @@ fun LoginScreen( } LoginStatus.LOGGED_IN -> { - Text("Logged in successfully! Redirecting...") + Text(stringResource(R.string.logged_in_message)) } } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt index 3ef6d22..eb7f89f 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -4,8 +4,10 @@ import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.R import com.notifier.app.ui.theme.GitHubNotifierTheme /** @@ -20,7 +22,7 @@ fun LoginButton(onClick: () -> Unit, modifier: Modifier = Modifier) { modifier = modifier, onClick = onClick, ) { - Text("Login with GitHub") + Text(stringResource(R.string.login_button_text)) } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt index 43ebf44..fe193d7 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -10,10 +10,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.notifier.app.R import com.notifier.app.ui.theme.GitHubNotifierTheme /** @@ -43,24 +45,24 @@ fun SetupScreen( ) { when (state.setupStep) { SetupStep.FETCHING_TOKEN -> { - Text(text = "Connecting to GitHub...") + Text(text = stringResource(R.string.fetching_token_state_message)) } SetupStep.SAVING_TOKEN -> { - Text(text = "Saving user information...") + Text(text = stringResource(R.string.saving_token_state_message)) } SetupStep.SUCCESS -> { - Text(text = "Connected successfully!") + Text(text = stringResource(R.string.setup_success_message)) Button( onClick = { onAction(SetupAction.OnContinueButtonClick) }, ) { - Text(text = "Continue") + Text(text = stringResource(R.string.continue_button_text)) } } SetupStep.FAILED -> { - Text(text = "Connection failed. Please try again.") + Text(text = stringResource(R.string.setup_failed_message)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f57a1d9..ef71109 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,12 @@ An unexpected error occurred. Please try again later. There was an issue with the data format. Please try again later. Something went wrong while accessing data. Please try again later. + Login with GitHub + Verifying authentication status… + Logged in successfully! Redirecting… + Connecting to GitHub… + Saving user information… + Connected successfully! + Continue + Connection failed. Please try again. From 24218551503d90acf13355396c353d6a65a685b1 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 31 May 2025 07:26:58 +0530 Subject: [PATCH 31/44] Refactor state management in ViewModels and enhance LoginScreen tests for loading state --- .../presentation/login/LoginScreenTest.kt | 19 +++++++++++++++++++ .../presentation/setup/SetupScreenTest.kt | 8 +++----- .../auth/presentation/setup/SetupViewModel.kt | 13 ------------- .../util/GitHubAuthIntentProvider.kt | 1 + .../app/core/presentation/BaseViewModel.kt | 2 +- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt index 6d54f68..d1f2772 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt @@ -13,6 +13,24 @@ class LoginScreenTest { @get:Rule val composeRule = createComposeRule() + @Test + fun testLoginScreen_whenStatusIsNull_showsProgressAndMessage() { + composeRule.setContent { + LoginScreen( + state = LoginState(status = null), + onLoginButtonClick = {} + ) + } + + composeRule + .onNode(hasTestTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR)) + .assertExists() + + composeRule + .onNodeWithText("Verifying authentication status...") + .assertIsDisplayed() + } + @Test fun testLoginScreen_whenLoading_showsProgressAndMessage() { composeRule.setContent { @@ -25,6 +43,7 @@ class LoginScreenTest { composeRule .onNode(hasTestTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR)) .assertExists() + composeRule .onNodeWithText("Verifying authentication status...") .assertIsDisplayed() diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt index 5306432..ccbde50 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt @@ -42,15 +42,13 @@ class SetupScreenTest { @Test fun testSetupScreen_whenSuccess_showsSuccessMessageAndContinueButton_clickTriggersCallback() { - var continueClicked = false + var capturedAction: SetupAction? = null composeRule.setContent { SetupScreen( state = SetupState(setupStep = SetupStep.SUCCESS), onAction = { - if (it == SetupAction.OnContinueButtonClick) { - continueClicked = true - } + capturedAction = it } ) } @@ -64,7 +62,7 @@ class SetupScreenTest { .assertIsDisplayed() .performClick() - assertThat(continueClicked).isTrue() + assertThat(capturedAction).isEqualTo(SetupAction.OnContinueButtonClick) } @Test diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index 1e9fe7c..f8993c0 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -11,8 +11,6 @@ import com.notifier.app.core.domain.util.onError import com.notifier.app.core.domain.util.onSuccess import com.notifier.app.core.presentation.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,17 +31,6 @@ class SetupViewModel @Inject constructor( private val authTokenDataSource: AuthTokenDataSource, private val dataStoreManager: DataStoreManager, ) : BaseViewModel(SetupState()) { - /** - * Public immutable state exposed to UI. - * - * Emits the current [SetupState] to the UI, which reflects progress through setup steps. - */ - override val state = mutableStateFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - initialValue = SetupState() - ) - /** * Handles user actions from the UI. * diff --git a/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt index c523a1b..22937d3 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt @@ -24,6 +24,7 @@ class GitHubAuthIntentProvider @Inject constructor( val clientId = BuildConfig.CLIENT_ID val state = UUID.randomUUID().toString() + // Store the state in DataStore for later validation runBlocking { dataStoreManager.setOAuthState(state) } diff --git a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt index 79d84a5..5f37539 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt @@ -20,7 +20,7 @@ abstract class BaseViewModel( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), - initialValue = initialState // Initial state before any flow emissions + initialValue = mutableStateFlow.value ) private val _events = Channel(Channel.BUFFERED) From ab617e582e6446da262099604e76f41e1bcbce61 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 31 May 2025 08:06:40 +0530 Subject: [PATCH 32/44] Update text assertions in LoginScreen and SetupScreen tests to use ellipsis character --- .../notifier/app/auth/presentation/login/LoginScreenTest.kt | 6 +++--- .../notifier/app/auth/presentation/setup/SetupScreenTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt index d1f2772..e9975f2 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt @@ -27,7 +27,7 @@ class LoginScreenTest { .assertExists() composeRule - .onNodeWithText("Verifying authentication status...") + .onNodeWithText("Verifying authentication status…") .assertIsDisplayed() } @@ -45,7 +45,7 @@ class LoginScreenTest { .assertExists() composeRule - .onNodeWithText("Verifying authentication status...") + .onNodeWithText("Verifying authentication status…") .assertIsDisplayed() } @@ -78,7 +78,7 @@ class LoginScreenTest { } composeRule - .onNodeWithText("Logged in successfully! Redirecting...") + .onNodeWithText("Logged in successfully! Redirecting…") .assertIsDisplayed() } } diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt index ccbde50..89bb0fc 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt @@ -22,7 +22,7 @@ class SetupScreenTest { } composeRule - .onNodeWithText("Connecting to GitHub...") + .onNodeWithText("Connecting to GitHub…") .assertIsDisplayed() } @@ -36,7 +36,7 @@ class SetupScreenTest { } composeRule - .onNodeWithText("Saving user information...") + .onNodeWithText("Saving user information…") .assertIsDisplayed() } From fb731c8b94ba461784f8251931805fdce8ce47c5 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 4 Jun 2025 08:00:51 +0530 Subject: [PATCH 33/44] Add LoginViewModel tests for authentication status and navigation events --- app/build.gradle.kts | 9 ++ .../presentation/login/LoginViewModelTest.kt | 112 ++++++++++++++++++ .../app/core/presentation/BaseViewModel.kt | 2 +- gradle/libs.versions.toml | 1 + 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d34e674..886c6bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,13 @@ android { buildConfig = true compose = true } + + packaging { + resources { + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } } dependencies { @@ -110,6 +117,8 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.dagger.hilt.testing) androidTestImplementation(libs.ktor.client.mock) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.mockk.android) kspAndroidTest(libs.dagger.hilt.compiler) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt new file mode 100644 index 0000000..5522faa --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt @@ -0,0 +1,112 @@ +package com.notifier.app.auth.presentation.login + +import com.google.common.truth.Truth.assertThat +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class LoginViewModelTest { + private lateinit var dataStoreManager: DataStoreManager + + private lateinit var viewModel: LoginViewModel + + @Before + fun setUp() { + dataStoreManager = mockk() + } + + @Test + fun testAuthStatus_whenTokenIsValid_setsStateToLoggedInEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("dummy_valid_token") + + viewModel = LoginViewModel(dataStoreManager) + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_IN + ).inOrder() + } + + @Test + fun testAuthStatus_whenTokenIsBlank_setsStateToLoggedOutEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("") + + viewModel = LoginViewModel(dataStoreManager) + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_OUT + ).inOrder() + } + + @Test + fun testAuthStatus_whenErrorFetchingToken_setsStateToLoggedOutEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Error(PersistenceError.IO) + + viewModel = LoginViewModel(dataStoreManager) + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_OUT + ).inOrder() + } + + @Test + fun testOnAction_loginButtonClick_emitsNavigateToGitHubAuthEvent() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("dummy_valid_token") + + viewModel = LoginViewModel(dataStoreManager) + viewModel.onAction(LoginAction.OnLoginButtonClick) + + val event = viewModel.events.first() + assertThat(event).isEqualTo(LoginEvent.NavigateToGitHubAuth) + } + + @Test + fun testOnAction_whenUserLoggedIn_emitsNavigateToHomeScreenEvent() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("dummy_valid_token") + + viewModel = LoginViewModel(dataStoreManager) + viewModel.onAction(LoginAction.OnUserLoggedIn) + + val event = viewModel.events.first() + assertThat(event).isEqualTo(LoginEvent.NavigateToHomeScreen) + } +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt index 5f37539..d3ac834 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch abstract class BaseViewModel( - private val initialState: State, + initialState: State, ) : ViewModel() { protected val mutableStateFlow = MutableStateFlow(initialState) open val state: StateFlow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24cc989..300103e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "daggerHilt" } dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "daggerHilt" } From ffa69e8a53c20907a6bf920fffcbd3cd439c06f9 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 4 Jun 2025 08:11:32 +0530 Subject: [PATCH 34/44] Refactor LoginViewModelTest to initialize ViewModel only once in setup --- .../app/auth/presentation/login/LoginViewModelTest.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt index 5522faa..0c7cd2f 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt @@ -22,6 +22,7 @@ class LoginViewModelTest { @Before fun setUp() { dataStoreManager = mockk() + viewModel = LoginViewModel(dataStoreManager) } @Test @@ -30,8 +31,6 @@ class LoginViewModelTest { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") - viewModel = LoginViewModel(dataStoreManager) - val stateStatuses = viewModel.state .take(3) .map { it.status } @@ -50,8 +49,6 @@ class LoginViewModelTest { dataStoreManager.getAccessToken() } returns Result.Success("") - viewModel = LoginViewModel(dataStoreManager) - val stateStatuses = viewModel.state .take(3) .map { it.status } @@ -70,8 +67,6 @@ class LoginViewModelTest { dataStoreManager.getAccessToken() } returns Result.Error(PersistenceError.IO) - viewModel = LoginViewModel(dataStoreManager) - val stateStatuses = viewModel.state .take(3) .map { it.status } @@ -90,7 +85,6 @@ class LoginViewModelTest { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") - viewModel = LoginViewModel(dataStoreManager) viewModel.onAction(LoginAction.OnLoginButtonClick) val event = viewModel.events.first() @@ -103,7 +97,6 @@ class LoginViewModelTest { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") - viewModel = LoginViewModel(dataStoreManager) viewModel.onAction(LoginAction.OnUserLoggedIn) val event = viewModel.events.first() From 9ad26665b207bdf6d76c9835e95fac2089d8e022 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 4 Jun 2025 08:55:47 +0530 Subject: [PATCH 35/44] Rename test methods in LoginViewModelTest for clarity and consistency --- .../app/auth/presentation/login/LoginViewModelTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt index 0c7cd2f..549f961 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt @@ -26,7 +26,7 @@ class LoginViewModelTest { } @Test - fun testAuthStatus_whenTokenIsValid_setsStateToLoggedInEventually() = runTest { + fun testAuthStatus_tokenIsValid_setsStateToLoggedInEventually() = runTest { coEvery { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") @@ -44,7 +44,7 @@ class LoginViewModelTest { } @Test - fun testAuthStatus_whenTokenIsBlank_setsStateToLoggedOutEventually() = runTest { + fun testAuthStatus_tokenIsBlank_setsStateToLoggedOutEventually() = runTest { coEvery { dataStoreManager.getAccessToken() } returns Result.Success("") @@ -62,7 +62,7 @@ class LoginViewModelTest { } @Test - fun testAuthStatus_whenErrorFetchingToken_setsStateToLoggedOutEventually() = runTest { + fun testAuthStatus_errorFetchingToken_setsStateToLoggedOutEventually() = runTest { coEvery { dataStoreManager.getAccessToken() } returns Result.Error(PersistenceError.IO) @@ -80,7 +80,7 @@ class LoginViewModelTest { } @Test - fun testOnAction_loginButtonClick_emitsNavigateToGitHubAuthEvent() = runTest { + fun testOnAction_onLoginButtonClick_emitsNavigateToGitHubAuthEvent() = runTest { coEvery { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") @@ -92,7 +92,7 @@ class LoginViewModelTest { } @Test - fun testOnAction_whenUserLoggedIn_emitsNavigateToHomeScreenEvent() = runTest { + fun testOnAction_onUserLoggedIn_emitsNavigateToHomeScreenEvent() = runTest { coEvery { dataStoreManager.getAccessToken() } returns Result.Success("dummy_valid_token") From 40adaa85d48477144cd286eaf48640c0bd61778e Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 4 Jun 2025 08:58:36 +0530 Subject: [PATCH 36/44] Add SetupViewModel tests for initial state and auth token handling --- .../presentation/setup/SetupViewModelTest.kt | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt new file mode 100644 index 0000000..03263a6 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -0,0 +1,151 @@ +package com.notifier.app.auth.presentation.setup + +import com.google.common.truth.Truth.assertThat +import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.PersistenceError +import com.notifier.app.core.domain.util.Result +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class SetupViewModelTest { + private lateinit var authTokenDataSource: AuthTokenDataSource + + private lateinit var dataStoreManager: DataStoreManager + + private lateinit var viewModel: SetupViewModel + + @Before + fun setUp() { + authTokenDataSource = mockk() + dataStoreManager = mockk() + viewModel = SetupViewModel(authTokenDataSource, dataStoreManager) + } + + @Test + fun testSetupState_initialStateIsCorrect() = runTest { + val initialState = viewModel.state.first() + assertThat(initialState.setupStep).isEqualTo(SetupStep.FETCHING_TOKEN) + assertThat(initialState.authToken).isNull() + } + + @Test + fun testGetAuthToken_codeIsNull_setsStateToFailed() = runTest { + viewModel.getAuthToken(code = null, receivedState = "dummy_valid_state") + + val finalState = viewModel.state.first() + assertThat(finalState).isEqualTo( + SetupState(SetupStep.FAILED, authToken = null) + ) + } + + @Test + fun testGetAuthToken_codeIsBlank_setsStateToFailed() = runTest { + viewModel.getAuthToken(code = "", receivedState = "dummy_valid_state") + + val finalState = viewModel.state.first() + assertThat(finalState).isEqualTo( + SetupState(SetupStep.FAILED, authToken = null) + ) + } + + @Test + fun testGetAuthToken_receivedOauthStateIsNull_setsStateToFailed() = runTest { + viewModel.getAuthToken(code = "dummy_valid_code", receivedState = null) + + val finalState = viewModel.state.first() + assertThat(finalState).isEqualTo( + SetupState(SetupStep.FAILED, authToken = null) + ) + } + + @Test + fun testGetAuthToken_receivedOauthStateIsBlank_setsStateToFailed() = runTest { + viewModel.getAuthToken(code = "dummy_valid_code", receivedState = "") + + val finalState = viewModel.state.first() + assertThat(finalState).isEqualTo( + SetupState(SetupStep.FAILED, authToken = null) + ) + } + + @Test + fun testGetAuthToken_receivedStateDoesNotMatchSavedOauthState_setsStateToFailedEventually() = + runTest { + coEvery { + dataStoreManager.getOAuthState() + } returns Result.Success("different_state") + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_invalid_state" + ) + + val stateStatuses = viewModel.state + .take(2) + .map { it } + .toList() + + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = null), + ).inOrder() + } + + @Test + fun testGetAuthToken_getOauthStateFailed_setsStateToFailedEventually() = + runTest { + coEvery { + dataStoreManager.getOAuthState() + } returns Result.Error(PersistenceError.IO) + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_valid_state" + ) + + val stateStatuses = viewModel.state + .take(2) + .map { it } + .toList() + + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = null), + ).inOrder() + } + + @Test + fun testGetAuthToken_clearOAuthStateFailed_setsStateToFailedEventually() = + runTest { + coEvery { + dataStoreManager.getOAuthState() + } returns Result.Success("dummy_valid_state") + + coEvery { + dataStoreManager.clearOAuthState() + } returns Result.Error(PersistenceError.IO) + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_valid_state" + ) + + val stateStatuses = viewModel.state + .take(2) + .map { it } + .toList() + + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = null), + ).inOrder() + } +} From 132ef540a0ed3cc35c96bb024d5f9908207668f1 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 4 Jun 2025 19:29:09 +0530 Subject: [PATCH 37/44] Refactor SetupViewModelTest to remove unnecessary map calls and improve coroutine handling --- .../app/auth/presentation/setup/SetupViewModelTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index 03263a6..4c8ca88 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -7,10 +7,11 @@ import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.Result import io.mockk.coEvery import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -90,7 +91,6 @@ class SetupViewModelTest { val stateStatuses = viewModel.state .take(2) - .map { it } .toList() assertThat(stateStatuses).containsExactly( @@ -113,7 +113,6 @@ class SetupViewModelTest { val stateStatuses = viewModel.state .take(2) - .map { it } .toList() assertThat(stateStatuses).containsExactly( @@ -122,6 +121,7 @@ class SetupViewModelTest { ).inOrder() } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun testGetAuthToken_clearOAuthStateFailed_setsStateToFailedEventually() = runTest { @@ -140,9 +140,10 @@ class SetupViewModelTest { val stateStatuses = viewModel.state .take(2) - .map { it } .toList() + advanceUntilIdle() + assertThat(stateStatuses).containsExactly( SetupState(SetupStep.FETCHING_TOKEN, authToken = null), SetupState(SetupStep.FAILED, authToken = null), From 18192d431444d434cd061bef1d1e6ebeb747717d Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 08:09:35 +0530 Subject: [PATCH 38/44] Enhance BaseViewModel with detailed documentation and improve error handling in SetupViewModel --- .../presentation/login/LoginViewModelTest.kt | 8 --- .../presentation/setup/SetupViewModelTest.kt | 62 +++++++++++++++++-- .../auth/presentation/setup/SetupViewModel.kt | 11 ++-- .../app/core/presentation/BaseViewModel.kt | 44 +++++++++++++ 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt index 549f961..95176e5 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt @@ -81,10 +81,6 @@ class LoginViewModelTest { @Test fun testOnAction_onLoginButtonClick_emitsNavigateToGitHubAuthEvent() = runTest { - coEvery { - dataStoreManager.getAccessToken() - } returns Result.Success("dummy_valid_token") - viewModel.onAction(LoginAction.OnLoginButtonClick) val event = viewModel.events.first() @@ -93,10 +89,6 @@ class LoginViewModelTest { @Test fun testOnAction_onUserLoggedIn_emitsNavigateToHomeScreenEvent() = runTest { - coEvery { - dataStoreManager.getAccessToken() - } returns Result.Success("dummy_valid_token") - viewModel.onAction(LoginAction.OnUserLoggedIn) val event = viewModel.events.first() diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index 4c8ca88..56b04bb 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -1,13 +1,15 @@ package com.notifier.app.auth.presentation.setup import com.google.common.truth.Truth.assertThat -import com.notifier.app.auth.domain.AuthTokenDataSource +import com.notifier.app.auth.data.networking.RemoteAuthTokenDataSource import com.notifier.app.core.data.persistence.DataStoreManager +import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.Result import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList @@ -16,8 +18,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class SetupViewModelTest { - private lateinit var authTokenDataSource: AuthTokenDataSource + private lateinit var authTokenDataSource: RemoteAuthTokenDataSource private lateinit var dataStoreManager: DataStoreManager @@ -121,7 +124,6 @@ class SetupViewModelTest { ).inOrder() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun testGetAuthToken_clearOAuthStateFailed_setsStateToFailedEventually() = runTest { @@ -138,15 +140,65 @@ class SetupViewModelTest { receivedState = "dummy_valid_state" ) + advanceUntilIdle() + val stateStatuses = viewModel.state .take(2) .toList() - advanceUntilIdle() - assertThat(stateStatuses).containsExactly( SetupState(SetupStep.FETCHING_TOKEN, authToken = null), SetupState(SetupStep.FAILED, authToken = null), ).inOrder() } + + @Test + fun testGetAuthToken_networkError_setsStateToFailedEventually_emitsNetworkErrorEvent() = + runTest { + coEvery { + dataStoreManager.getOAuthState() + } returns Result.Success("dummy_valid_state") + + coEvery { + dataStoreManager.clearOAuthState() + } returns Result.Success(Unit) + + coEvery { + authTokenDataSource.getAuthToken( + clientId = any(), + clientSecret = any(), + code = "dummy_valid_code" + ) + } returns Result.Error(NetworkError.SERVER_ERROR) + + val eventDeferred = async { + viewModel.events.first() + } + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_valid_state" + ) + + val stateStatuses = viewModel.state.take(2).toList() + + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = null) + ).inOrder() + + val event = eventDeferred.await() + + assertThat(event).isEqualTo( + SetupEvent.NetworkErrorEvent(NetworkError.SERVER_ERROR) + ) + } + + @Test + fun testOnAction_onContinueButtonClick_emitsNavigateToHomeScreenEvent() = runTest { + viewModel.onAction(SetupAction.OnContinueButtonClick) + + val event = viewModel.events.first() + assertThat(event).isEqualTo(SetupEvent.NavigateToHomeScreen) + } } diff --git a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt index f8993c0..f2a25a1 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -118,7 +118,6 @@ class SetupViewModel @Inject constructor( dataStoreManager.setAccessToken(token).onSuccess { updateSetupStep(SetupStep.SUCCESS) }.onError { error -> - failSetup() handlePersistenceError(error) } } @@ -164,12 +163,16 @@ class SetupViewModel @Inject constructor( } /** - * Emits a [SetupEvent.PersistenceErrorEvent] when a persistence error occurs. + * Emits a [SetupEvent.PersistenceErrorEvent] and marks setup as failed. * * @param error the persistence error that occurred */ private fun handlePersistenceError(error: Error) { - val actualError = if (error is PersistenceError) error else PersistenceError.UNKNOWN - sendEvent(SetupEvent.PersistenceErrorEvent(actualError)) + failSetup() + sendEvent( + SetupEvent.PersistenceErrorEvent( + if (error is PersistenceError) error else PersistenceError.UNKNOWN + ) + ) } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt index d3ac834..83bfeb3 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt @@ -11,10 +11,30 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** + * A generic base [ViewModel] that handles UI state, user actions, and one-time events + * in a structured and reactive manner using Kotlin's coroutines and Flow APIs. + * + * @param State The type representing the UI state. + * @param Event The type representing one-time events (e.g., navigation, dialogs). + * @param Action The type representing user actions or intents. + * @param initialState The initial state for the screen. + */ abstract class BaseViewModel( initialState: State, ) : ViewModel() { + + /** + * Internal mutable state holder, updated by the ViewModel. + */ protected val mutableStateFlow = MutableStateFlow(initialState) + + /** + * Exposed immutable [StateFlow] that represents the current UI state. + * + * The state is kept active while there are active subscribers. It automatically + * stops collecting updates after 5 seconds of inactivity to conserve resources. + */ open val state: StateFlow get() = mutableStateFlow .stateIn( @@ -23,11 +43,35 @@ abstract class BaseViewModel( initialValue = mutableStateFlow.value ) + /** + * A buffered [Channel] used to send one-time [Event]s such as navigation or showing error + * messages. + */ private val _events = Channel(Channel.BUFFERED) + + /** + * A [Flow] that emits one-time [Event]s to be consumed by the UI. + * + * This should be collected using a lifecycle-aware collector (e.g., in a Compose + * LaunchedEffect or Fragment). + */ val events: Flow = _events.receiveAsFlow() + /** + * Called when an [Action] is dispatched from the UI. + * + * Implementing ViewModels should override this to handle business logic based on the action. + */ abstract fun onAction(action: Action) + /** + * Sends a one-time [Event] to the UI. + * + * This is useful for things like navigation, showing snack bars, or triggering dialogs. + * This method launches a coroutine within the [viewModelScope]. + * + * @param event The event to send to the UI. + */ protected fun sendEvent(event: Event) { viewModelScope.launch { _events.send(event) } } From 478a1f303f0ef237d5e1a75c13e9ec805a9b4dcc Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 08:49:59 +0530 Subject: [PATCH 39/44] Refactor SetupViewModelTest to improve test clarity and structure with helper methods --- .../presentation/setup/SetupViewModelTest.kt | 77 ++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index 56b04bb..0afe946 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -2,23 +2,21 @@ package com.notifier.app.auth.presentation.setup import com.google.common.truth.Truth.assertThat import com.notifier.app.auth.data.networking.RemoteAuthTokenDataSource +import com.notifier.app.auth.domain.AuthToken import com.notifier.app.core.data.persistence.DataStoreManager import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.PersistenceError import com.notifier.app.core.domain.util.Result import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test -@OptIn(ExperimentalCoroutinesApi::class) class SetupViewModelTest { private lateinit var authTokenDataSource: RemoteAuthTokenDataSource @@ -140,8 +138,6 @@ class SetupViewModelTest { receivedState = "dummy_valid_state" ) - advanceUntilIdle() - val stateStatuses = viewModel.state .take(2) .toList() @@ -155,21 +151,9 @@ class SetupViewModelTest { @Test fun testGetAuthToken_networkError_setsStateToFailedEventually_emitsNetworkErrorEvent() = runTest { - coEvery { - dataStoreManager.getOAuthState() - } returns Result.Success("dummy_valid_state") - - coEvery { - dataStoreManager.clearOAuthState() - } returns Result.Success(Unit) - - coEvery { - authTokenDataSource.getAuthToken( - clientId = any(), - clientSecret = any(), - code = "dummy_valid_code" - ) - } returns Result.Error(NetworkError.SERVER_ERROR) + mockGetOAuthStateSuccess() + mockClearOAuthStateSuccess() + mockGetAuthTokenError() val eventDeferred = async { viewModel.events.first() @@ -201,4 +185,57 @@ class SetupViewModelTest { val event = viewModel.events.first() assertThat(event).isEqualTo(SetupEvent.NavigateToHomeScreen) } + + private fun mockGetOAuthStateSuccess(state: String = "dummy_valid_state") { + coEvery { dataStoreManager.getOAuthState() } returns Result.Success(state) + } + + private fun mockGetOAuthStateError(error: PersistenceError = PersistenceError.IO) { + coEvery { dataStoreManager.getOAuthState() } returns Result.Error(error) + } + + private fun mockClearOAuthStateSuccess() { + coEvery { dataStoreManager.clearOAuthState() } returns Result.Success(Unit) + } + + private fun mockClearOAuthStateError(error: PersistenceError = PersistenceError.IO) { + coEvery { dataStoreManager.clearOAuthState() } returns Result.Error(error) + } + + private fun mockGetAuthTokenError(error: NetworkError = NetworkError.SERVER_ERROR) { + coEvery { + authTokenDataSource.getAuthToken( + clientId = any(), + clientSecret = any(), + code = "dummy_valid_code" + ) + } returns Result.Error(error) + } + + private fun mockGetAuthTokenSuccess() { + coEvery { + authTokenDataSource.getAuthToken( + clientId = any(), + clientSecret = any(), + code = "dummy_valid_code" + ) + } returns Result.Success(dummyValidAuthToken) + } + + private fun mockSetAccessTokenError( + accessToken: String = "dummy_valid_access_token", + error: PersistenceError = PersistenceError.IO + ) { + coEvery { dataStoreManager.setAccessToken(accessToken) } returns Result.Error(error) + } + + private fun mockSetAccessTokenSuccess(accessToken: String = "dummy_valid_access_token") { + coEvery { dataStoreManager.setAccessToken(accessToken) } returns Result.Success(Unit) + } + + private val dummyValidAuthToken = AuthToken( + accessToken = "dummy_valid_access_token", + scope = "dummy_valid_scope", + tokenType = "dummy_valid_token_type" + ) } From 9d01787d7c111d4a573dba17f56363437e1d1250 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 08:51:11 +0530 Subject: [PATCH 40/44] Refactor SetupViewModelTest to use mock methods for OAuth state handling --- .../presentation/setup/SetupViewModelTest.kt | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index 0afe946..f714bd8 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -81,9 +81,7 @@ class SetupViewModelTest { @Test fun testGetAuthToken_receivedStateDoesNotMatchSavedOauthState_setsStateToFailedEventually() = runTest { - coEvery { - dataStoreManager.getOAuthState() - } returns Result.Success("different_state") + mockGetOAuthStateSuccess() viewModel.getAuthToken( code = "dummy_valid_code", @@ -103,9 +101,7 @@ class SetupViewModelTest { @Test fun testGetAuthToken_getOauthStateFailed_setsStateToFailedEventually() = runTest { - coEvery { - dataStoreManager.getOAuthState() - } returns Result.Error(PersistenceError.IO) + mockGetOAuthStateError() viewModel.getAuthToken( code = "dummy_valid_code", @@ -125,13 +121,8 @@ class SetupViewModelTest { @Test fun testGetAuthToken_clearOAuthStateFailed_setsStateToFailedEventually() = runTest { - coEvery { - dataStoreManager.getOAuthState() - } returns Result.Success("dummy_valid_state") - - coEvery { - dataStoreManager.clearOAuthState() - } returns Result.Error(PersistenceError.IO) + mockGetOAuthStateSuccess() + mockClearOAuthStateError() viewModel.getAuthToken( code = "dummy_valid_code", From e86b94079cbedfae9068d3822b4354990cc36049 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 09:11:27 +0530 Subject: [PATCH 41/44] Refactor LoginViewModel to remove logging on auth error and simplify error handling --- .../notifier/app/auth/presentation/login/LoginViewModel.kt | 6 ++---- .../app/auth/presentation/login/LoginViewModelTest.kt | 0 .../app/auth/presentation/setup/SetupViewModelTest.kt | 0 3 files changed, 2 insertions(+), 4 deletions(-) rename app/src/{androidTest => test}/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt (100%) rename app/src/{androidTest => test}/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt (100%) diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index 76b9a54..dd3092c 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -1,6 +1,5 @@ package com.notifier.app.auth.presentation.login -import android.util.Log import androidx.lifecycle.viewModelScope import com.notifier.app.core.data.persistence.DataStoreManager import com.notifier.app.core.domain.util.onError @@ -71,7 +70,7 @@ class LoginViewModel @Inject constructor( * - [LoginStatus.LOGGED_IN] if a non-blank token exists * - [LoginStatus.LOGGED_OUT] otherwise * - * If an error occurs, logs the error and treats user as logged out. + * If an error occurs, treats user as logged out. */ private fun checkAuthStatus() { viewModelScope.launch { @@ -84,8 +83,7 @@ class LoginViewModel @Inject constructor( ) } } - .onError { error -> - Log.e(TAG, "Error fetching access token: $error") + .onError { _ -> mutableStateFlow.update { it.copy(status = LoginStatus.LOGGED_OUT) } } } diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/test/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt similarity index 100% rename from app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt rename to app/src/test/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/test/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt similarity index 100% rename from app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt rename to app/src/test/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt From 26547ec0f90937cb13e88d675ba862fb26ca6ab5 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 09:18:57 +0530 Subject: [PATCH 42/44] Revert "Refactor LoginViewModel to remove logging on auth error and simplify error handling" This reverts commit e86b94079cbedfae9068d3822b4354990cc36049. --- .../app/auth/presentation/login/LoginViewModelTest.kt | 0 .../app/auth/presentation/setup/SetupViewModelTest.kt | 0 .../notifier/app/auth/presentation/login/LoginViewModel.kt | 6 ++++-- 3 files changed, 4 insertions(+), 2 deletions(-) rename app/src/{test => androidTest}/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt (100%) rename app/src/{test => androidTest}/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt (100%) diff --git a/app/src/test/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt similarity index 100% rename from app/src/test/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt rename to app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt diff --git a/app/src/test/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt similarity index 100% rename from app/src/test/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt rename to app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt diff --git a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt index dd3092c..76b9a54 100644 --- a/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -1,5 +1,6 @@ package com.notifier.app.auth.presentation.login +import android.util.Log import androidx.lifecycle.viewModelScope import com.notifier.app.core.data.persistence.DataStoreManager import com.notifier.app.core.domain.util.onError @@ -70,7 +71,7 @@ class LoginViewModel @Inject constructor( * - [LoginStatus.LOGGED_IN] if a non-blank token exists * - [LoginStatus.LOGGED_OUT] otherwise * - * If an error occurs, treats user as logged out. + * If an error occurs, logs the error and treats user as logged out. */ private fun checkAuthStatus() { viewModelScope.launch { @@ -83,7 +84,8 @@ class LoginViewModel @Inject constructor( ) } } - .onError { _ -> + .onError { error -> + Log.e(TAG, "Error fetching access token: $error") mutableStateFlow.update { it.copy(status = LoginStatus.LOGGED_OUT) } } } From ba8c99d10f4b81cf3be5545d7da44cf0ed5f5456 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 10:00:42 +0530 Subject: [PATCH 43/44] Add tests for getAuthToken in SetupViewModel to verify state transitions and error events --- .../presentation/setup/SetupViewModelTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index f714bd8..3570a46 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -169,6 +169,62 @@ class SetupViewModelTest { ) } + @Test + fun testGetAuthToken_setAccessTokenFailed_setsStateToFailedEventually_emitsErrorEvent() = + runTest { + mockGetOAuthStateSuccess() + mockClearOAuthStateSuccess() + mockGetAuthTokenSuccess() + mockSetAccessTokenError() + + val eventDeferred = async { + viewModel.events.first() + } + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_valid_state" + ) + + val stateStatuses = viewModel.state.take(2).toList() + + // The SAVING_TOKEN step is overwritten before it is collected. + // So, FAILED step is directly received after FETCHING_TOKEN. + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = dummyValidAuthToken) + ).inOrder() + + val event = eventDeferred.await() + + assertThat(event).isEqualTo( + SetupEvent.PersistenceErrorEvent(PersistenceError.IO) + ) + } + + @Test + fun testGetAuthToken_successfulFlow_setsStateToSuccessEventually() = + runTest { + mockGetOAuthStateSuccess() + mockClearOAuthStateSuccess() + mockGetAuthTokenSuccess() + mockSetAccessTokenSuccess() + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_valid_state" + ) + + val stateStatuses = viewModel.state.take(2).toList() + + // The SAVING_TOKEN step is overwritten before it is collected. + // So, SUCCESS step is directly received after FETCHING_TOKEN. + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.SUCCESS, authToken = dummyValidAuthToken) + ).inOrder() + } + @Test fun testOnAction_onContinueButtonClick_emitsNavigateToHomeScreenEvent() = runTest { viewModel.onAction(SetupAction.OnContinueButtonClick) From 3ecbbf6030cd7267c1c44dd8c10d588e4d857245 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Thu, 5 Jun 2025 10:13:52 +0530 Subject: [PATCH 44/44] Fix formatting in mockSetAccessTokenError function in SetupViewModelTest --- .../notifier/app/auth/presentation/setup/SetupViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt index 3570a46..814b7f6 100644 --- a/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -271,7 +271,7 @@ class SetupViewModelTest { private fun mockSetAccessTokenError( accessToken: String = "dummy_valid_access_token", - error: PersistenceError = PersistenceError.IO + error: PersistenceError = PersistenceError.IO, ) { coEvery { dataStoreManager.setAccessToken(accessToken) } returns Result.Error(error) }