diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ffc0a..886c6bc 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,39 @@ android { } buildTypes { + val properties = Properties().apply { + 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", "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", "dummy_client_id")}\"" + ) + buildConfigField( + "String", + "CLIENT_SECRET", + "\"${properties.getProperty("CLIENT_SECRET", "dummy_client_secret")}\"" + ) proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -48,6 +77,13 @@ android { buildConfig = true compose = true } + + packaging { + resources { + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } } dependencies { @@ -59,9 +95,14 @@ 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.androidx.datastore.preferences) 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) @@ -70,11 +111,14 @@ dependencies { 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) + 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/LoginScreenTest.kt b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt new file mode 100644 index 0000000..e9975f2 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginScreenTest.kt @@ -0,0 +1,84 @@ +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_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 { + LoginScreen( + state = LoginState(status = LoginStatus.LOADING), + onLoginButtonClick = {} + ) + } + + composeRule + .onNode(hasTestTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR)) + .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/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..95176e5 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/login/LoginViewModelTest.kt @@ -0,0 +1,97 @@ +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() + viewModel = LoginViewModel(dataStoreManager) + } + + @Test + fun testAuthStatus_tokenIsValid_setsStateToLoggedInEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("dummy_valid_token") + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_IN + ).inOrder() + } + + @Test + fun testAuthStatus_tokenIsBlank_setsStateToLoggedOutEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Success("") + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_OUT + ).inOrder() + } + + @Test + fun testAuthStatus_errorFetchingToken_setsStateToLoggedOutEventually() = runTest { + coEvery { + dataStoreManager.getAccessToken() + } returns Result.Error(PersistenceError.IO) + + val stateStatuses = viewModel.state + .take(3) + .map { it.status } + .toList() + + assertThat(stateStatuses).containsExactly( + null, + LoginStatus.LOADING, + LoginStatus.LOGGED_OUT + ).inOrder() + } + + @Test + fun testOnAction_onLoginButtonClick_emitsNavigateToGitHubAuthEvent() = runTest { + viewModel.onAction(LoginAction.OnLoginButtonClick) + + val event = viewModel.events.first() + assertThat(event).isEqualTo(LoginEvent.NavigateToGitHubAuth) + } + + @Test + fun testOnAction_onUserLoggedIn_emitsNavigateToHomeScreenEvent() = runTest { + viewModel.onAction(LoginAction.OnUserLoggedIn) + + val event = viewModel.events.first() + assertThat(event).isEqualTo(LoginEvent.NavigateToHomeScreen) + } +} 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..89bb0fc --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupScreenTest.kt @@ -0,0 +1,81 @@ +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 capturedAction: SetupAction? = null + + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.SUCCESS), + onAction = { + capturedAction = it + } + ) + } + + composeRule + .onNodeWithText("Connected successfully!") + .assertIsDisplayed() + + composeRule + .onNodeWithText("Continue") + .assertIsDisplayed() + .performClick() + + assertThat(capturedAction).isEqualTo(SetupAction.OnContinueButtonClick) + } + + @Test + fun testSetupScreen_whenFailed_showsErrorMessage() { + composeRule.setContent { + SetupScreen( + state = SetupState(setupStep = SetupStep.FAILED), + onAction = {} + ) + } + + composeRule + .onNodeWithText("Connection failed. Please try again.") + .assertIsDisplayed() + } +} 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..814b7f6 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/auth/presentation/setup/SetupViewModelTest.kt @@ -0,0 +1,288 @@ +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.async +import kotlinx.coroutines.flow.first +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: RemoteAuthTokenDataSource + + 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 { + mockGetOAuthStateSuccess() + + viewModel.getAuthToken( + code = "dummy_valid_code", + receivedState = "dummy_invalid_state" + ) + + val stateStatuses = viewModel.state + .take(2) + .toList() + + assertThat(stateStatuses).containsExactly( + SetupState(SetupStep.FETCHING_TOKEN, authToken = null), + SetupState(SetupStep.FAILED, authToken = null), + ).inOrder() + } + + @Test + fun testGetAuthToken_getOauthStateFailed_setsStateToFailedEventually() = + runTest { + mockGetOAuthStateError() + + 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() + } + + @Test + fun testGetAuthToken_clearOAuthStateFailed_setsStateToFailedEventually() = + runTest { + mockGetOAuthStateSuccess() + mockClearOAuthStateError() + + 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() + } + + @Test + fun testGetAuthToken_networkError_setsStateToFailedEventually_emitsNetworkErrorEvent() = + runTest { + mockGetOAuthStateSuccess() + mockClearOAuthStateSuccess() + mockGetAuthTokenError() + + 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 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) + + 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" + ) +} 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 f9b22c2..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,10 +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 @@ -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) + assertThat(result).isInstanceOf(Result.Success::class.java) + // Retrieve the token val retrieved = dataStoreManager.getAccessToken() - assertEquals(token, retrieved) + assertThat(retrieved).isInstanceOf(Result.Success::class.java) + assertThat((retrieved as Result.Success).data).isEqualTo(token) } @Test fun testGetAccessToken_whenTokenNotSet_returnsEmptyString() = runTest { - val token = dataStoreManager.getAccessToken() - assertEquals("", token) + val result = dataStoreManager.getAccessToken() + assertThat(result).isInstanceOf(Result.Success::class.java) + assertThat((result as Result.Success).data).isEqualTo("") } @Test fun testSetAccessToken_withEmptyString_returnsEmptyString() = runTest { - dataStoreManager.setAccessToken("") + val result = dataStoreManager.setAccessToken("") + assertThat(result).isInstanceOf(Result.Success::class.java) + val token = dataStoreManager.getAccessToken() - assertEquals("", token) + assertThat(token).isInstanceOf(Result.Success::class.java) + assertThat((token as Result.Success).data).isEqualTo("") } @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) + assertThat(result).isInstanceOf(Result.Success::class.java) + + // Set updated token + result = dataStoreManager.setAccessToken(updatedToken) + assertThat(result).isInstanceOf(Result.Success::class.java) + // Retrieve the updated token val retrievedToken = dataStoreManager.getAccessToken() - assertEquals(updatedToken, retrievedToken) + assertThat(retrievedToken).isInstanceOf(Result.Success::class.java) + assertThat((retrievedToken as Result.Success).data).isEqualTo(updatedToken) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fcb03..8f685c1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,21 +2,32 @@ + + - + + + + + + + + diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 5f178ef..2a181c6 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -7,43 +7,94 @@ 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 androidx.compose.ui.platform.LocalContext +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +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.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() enableEdgeToEdge() setContent { GitHubNotifierTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + MainAppContent() } } } -} -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} + @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 + } + } + }, + onNavigateToGitHubAuth = { + val authIntentPair = gitHubAuthIntentProvider.createGitHubAuthIntent() + context.startActivity(authIntentPair.first) + } + ) + } -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - GitHubNotifierTheme { - Greeting("Android") + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val args = it.toRoute() + SetupRoute( + code = args.code, + receivedState = args.state, + onNavigateToHomeScreen = { + navController.navigate(NotificationScreen) { + popUpTo(LoginScreen) { + inclusive = true + } + } + } + ) + } + + composable { + NotificationRoute() + } + } + } } } 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..98aaa4a --- /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.parameter +import io.ktor.client.request.post +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.post( + 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..f1800c3 --- /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.Error +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/login/LoginAction.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt new file mode 100644 index 0000000..ab13a97 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginAction.kt @@ -0,0 +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 new file mode 100644 index 0000000..ccb3b5e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..f727b41 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginRoute.kt @@ -0,0 +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.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(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + LoginEvent.NavigateToHomeScreen -> onNavigateToHomeScreen() + 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, + 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 new file mode 100644 index 0000000..146840c --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -0,0 +1,105 @@ +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 +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.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 + +const val TEST_TAG_CIRCULAR_PROGRESS_INDICATOR = "TestTag.CircularProgressIndicator" + +/** + * 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, + onLoginButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (state.status) { + LoginStatus.LOADING, null -> { + CircularProgressIndicator( + modifier = Modifier.testTag(TEST_TAG_CIRCULAR_PROGRESS_INDICATOR), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(R.string.logging_in_loading_message)) + } + + LoginStatus.LOGGED_OUT -> { + LoginButton(onClick = onLoginButtonClick) + } + + LoginStatus.LOGGED_IN -> { + Text(stringResource(R.string.logged_in_message)) + } + } + } +} + +/** + * 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( + @PreviewParameter(LoginStateParameterProvider::class) state: LoginState, +) { + GitHubNotifierTheme { + Scaffold { innerPadding -> + LoginScreen( + 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 new file mode 100644 index 0000000..d9b2111 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -0,0 +1,33 @@ +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? = 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 new file mode 100644 index 0000000..76b9a54 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -0,0 +1,93 @@ +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 +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.StateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +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, +) : BaseViewModel(LoginState()) { + companion object { + /** Tag used for logging errors from this ViewModel. */ + private const val TAG = "LoginViewModel" + } + + /** + * Public immutable state exposed to UI. + * + * - Triggers [checkAuthStatus] when the flow starts collecting (via [onStart]) + * - Emits the current [LoginState] to the UI + */ + override val state: StateFlow = mutableStateFlow + .onStart { + // Mark the UI as loading while checking authentication status + mutableStateFlow.update { it.copy(status = LoginStatus.LOADING) } + checkAuthStatus() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = LoginState() // Initial state before any flow emissions + ) + + /** + * Handles user actions from the UI. + * + * @param action the user-triggered action + */ + override fun onAction(action: LoginAction) { + when (action) { + LoginAction.OnLoginButtonClick -> sendEvent(LoginEvent.NavigateToGitHubAuth) + LoginAction.OnUserLoggedIn -> sendEvent(LoginEvent.NavigateToHomeScreen) + } + } + + /** + * Checks if the user is already authenticated by retrieving the saved access token. + * + * Updates [mutableStateFlow] 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 -> + mutableStateFlow.update { + it.copy( + status = if (accessToken.isBlank()) LoginStatus.LOGGED_OUT + else LoginStatus.LOGGED_IN + ) + } + } + .onError { error -> + Log.e(TAG, "Error fetching access token: $error") + mutableStateFlow.update { it.copy(status = LoginStatus.LOGGED_OUT) } + } + } + } +} 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 new file mode 100644 index 0000000..eb7f89f --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -0,0 +1,39 @@ +package com.notifier.app.auth.presentation.login.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.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 + +/** + * 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) { + Button( + modifier = modifier, + onClick = onClick, + ) { + Text(stringResource(R.string.login_button_text)) + } +} + +/** + * Preview function for the LoginButton composable in both light and dark modes with dynamic colors. + */ +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun LoginButtonPreview() { + GitHubNotifierTheme { + 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..289e469 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupAction.kt @@ -0,0 +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 new file mode 100644 index 0000000..d1d2b85 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..118fa8f --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -0,0 +1,79 @@ +package com.notifier.app.auth.presentation.setup + +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.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?, + receivedState: String?, + onNavigateToHomeScreen: () -> Unit, + viewModel: SetupViewModel = hiltViewModel(), +) { + 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 new file mode 100644 index 0000000..fe193d7 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -0,0 +1,107 @@ +package com.notifier.app.auth.presentation.setup + +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 +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 + +/** + * 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, + onAction: (SetupAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (state.setupStep) { + SetupStep.FETCHING_TOKEN -> { + Text(text = stringResource(R.string.fetching_token_state_message)) + } + + SetupStep.SAVING_TOKEN -> { + Text(text = stringResource(R.string.saving_token_state_message)) + } + + SetupStep.SUCCESS -> { + Text(text = stringResource(R.string.setup_success_message)) + Button( + onClick = { onAction(SetupAction.OnContinueButtonClick) }, + ) { + Text(text = stringResource(R.string.continue_button_text)) + } + } + + SetupStep.FAILED -> { + Text(text = stringResource(R.string.setup_failed_message)) + } + } + } +} + +/** + * 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( + @PreviewParameter(SetupStateParameterProvider::class) state: SetupState, +) { + GitHubNotifierTheme { + Scaffold { innerPadding -> + SetupScreen( + 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 new file mode 100644 index 0000000..b542664 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupState.kt @@ -0,0 +1,35 @@ +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 and is now being saved locally. */ + SAVING_TOKEN, + + /** Token has been saved successfully and setup is complete. */ + SUCCESS, + + /** 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 new file mode 100644 index 0000000..f2a25a1 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -0,0 +1,178 @@ +package com.notifier.app.auth.presentation.setup + +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 +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.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, +) : BaseViewModel(SetupState()) { + /** + * Handles user actions from the UI. + * + * @param action the user-triggered action + */ + override fun onAction(action: SetupAction) { + when (action) { + is SetupAction.OnContinueButtonClick -> navigateToHome() + } + } + + /** + * Handles the OAuth redirect by validating state and exchanging the authorization code + * for an access token. + * + * 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 + */ + fun getAuthToken(code: String?, receivedState: String?) { + if (code.isNullOrBlank() || receivedState.isNullOrBlank()) { + failSetup() + return + } + + 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 -> + updateSetupStep(SetupStep.SAVING_TOKEN) + mutableStateFlow.update { it.copy(authToken = authToken) } + saveAuthToken(authToken.accessToken) + }.onError { error -> + handleNetworkError(error) + } + } + } + + /** + * 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 = "" + 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. + * + * Emits a [SetupEvent.PersistenceErrorEvent] if saving fails. + * + * @param token the OAuth access token to save + */ + private fun saveAuthToken(token: String) { + viewModelScope.launch { + dataStoreManager.setAccessToken(token).onSuccess { + updateSetupStep(SetupStep.SUCCESS) + }.onError { error -> + handlePersistenceError(error) + } + } + } + + /** + * Sends an event to navigate to the home screen. + */ + private fun navigateToHome() { + sendEvent(SetupEvent.NavigateToHomeScreen) + } + + /** + * Updates the current setup step in [mutableStateFlow]. + * + * @param step the new setup step + */ + private fun updateSetupStep(step: SetupStep) { + mutableStateFlow.update { it.copy(setupStep = step) } + } + + /** + * Marks the setup process as failed. + * + * Updates [mutableStateFlow] and prevents further progress. + */ + private fun failSetup() { + updateSetupStep(SetupStep.FAILED) + } + + /** + * Emits a [SetupEvent.NetworkErrorEvent] and marks setup as failed. + * + * @param error the network error that occurred + */ + private fun handleNetworkError(error: Error) { + failSetup() + sendEvent( + SetupEvent.NetworkErrorEvent( + if (error is NetworkError) error else NetworkError.UNKNOWN + ) + ) + } + + /** + * Emits a [SetupEvent.PersistenceErrorEvent] and marks setup as failed. + * + * @param error the persistence error that occurred + */ + private fun handlePersistenceError(error: Error) { + failSetup() + sendEvent( + SetupEvent.PersistenceErrorEvent( + if (error is PersistenceError) error else PersistenceError.UNKNOWN + ) + ) + } +} 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..22937d3 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/util/GitHubAuthIntentProvider.kt @@ -0,0 +1,36 @@ +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() + + // Store the state in DataStore for later validation + 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/core/data/networking/HttpClientFactory.kt b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt index cd6a215..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 @@ -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 + } + return@runBlocking 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/core/data/persistence/DataStoreManager.kt b/app/src/main/java/com/notifier/app/core/data/persistence/DataStoreManager.kt index 224a47b..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 @@ -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,38 +13,65 @@ 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( private val dataStore: DataStore, ) { - /** * 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 } } + + /** + * 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") } 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/.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/BaseViewModel.kt b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt new file mode 100644 index 0000000..83bfeb3 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/BaseViewModel.kt @@ -0,0 +1,78 @@ +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 + +/** + * 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( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + 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) } + } +} 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..5053e1d --- /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/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/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index e4dd221..74d1010 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,24 @@ 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 - fun provideHttpClient(): HttpClient { - return HttpClientFactory.create(CIO.create()) + @Singleton + fun provideHttpClient( + httpClientFactory: HttpClientFactory, + ): HttpClient { + return httpClientFactory.create(CIO.create()) + } + + @Provides + @Singleton + fun provideRemoteAuthTokenDataSource( + httpClient: HttpClient, + ): AuthTokenDataSource { + return RemoteAuthTokenDataSource(httpClient) } } 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/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) + ) + } + } +} diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml new file mode 100644 index 0000000..5c46377 --- /dev/null +++ b/app/src/main/res/values/splash.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9719f0..ef71109 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,17 @@ GitHub Notifier + The request timed out. + Oops, it seems like your quota is exceeded. + Couldn\'t connect to server, please check your internet connection. + 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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8322eb..300103e 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" @@ -13,13 +13,16 @@ 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" +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" } 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" } @@ -31,6 +34,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" } @@ -40,10 +44,13 @@ 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 = "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" } +androidx-splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -51,7 +58,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 = [