diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ffc0a..aff72b2 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" @@ -59,9 +88,12 @@ 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) + ksp(libs.dagger.hilt.compiler) testImplementation(libs.junit) 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20fcb03..7f1665b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + - + + + + + + + + diff --git a/app/src/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 5f178ef..dd9830f 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -7,10 +7,17 @@ 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.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.ui.theme.GitHubNotifierTheme import dagger.hilt.android.AndroidEntryPoint @@ -21,29 +28,36 @@ class MainActivity : ComponentActivity() { 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 - ) -} +private fun MainAppContent() { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val navController = rememberNavController() -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - GitHubNotifierTheme { - Greeting("Android") + NavHost( + navController = navController, + startDestination = LoginScreen, + modifier = Modifier.padding(innerPadding) + ) { + composable { + LoginRoute() + } + + composable( + deepLinks = listOf( + navDeepLink( + basePath = "github-notifier://auth-callback" + ) + ) + ) { + val args = it.toRoute() + SetupRoute(code = args.code) + } + } } } 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..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/LoginEvent.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt new file mode 100644 index 0000000..50af57b --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginEvent.kt @@ -0,0 +1,7 @@ +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 +} 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..5afc90f --- /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: LoginViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(events = viewModel.events) { event -> + when (event) { + is LoginEvent.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/login/LoginScreen.kt b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt new file mode 100644 index 0000000..93727a8 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginScreen.kt @@ -0,0 +1,44 @@ +package com.notifier.app.auth.presentation.login + +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.login.components.LoginButton +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun LoginScreen( + state: LoginState, + onAction: (LoginAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoginButton() + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun LoginScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + LoginScreen( + state = LoginState(), + onAction = {}, + 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..0fc2e8e --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginState.kt @@ -0,0 +1,10 @@ +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, +) 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..4ce1169 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/LoginViewModel.kt @@ -0,0 +1,31 @@ +package com.notifier.app.auth.presentation.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 LoginViewModel @Inject constructor() : ViewModel() { + private val _state = MutableStateFlow(LoginState()) + val state = _state + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + LoginState() + ) + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun onAction(action: LoginAction) { + when (action) { + is LoginAction.OnLoginButtonClick -> {} + } + } +} 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..05f213f --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/login/components/LoginButton.kt @@ -0,0 +1,33 @@ +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.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) { + val context = LocalContext.current + Button( + modifier = modifier, + onClick = { + context.startActivity(createGitHubAuthIntent()) + }, + ) { + Text("Login with GitHub") + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun LoginButtonPreview() { + GitHubNotifierTheme { + LoginButton() + } +} 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..564b867 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupEvent.kt @@ -0,0 +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 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 new file mode 100644 index 0000000..26f44c3 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupRoute.kt @@ -0,0 +1,45 @@ +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 + +@Serializable +data class SetupScreen( + val code: String? = null, + val state: String? = null, +) + +@Composable +fun SetupRoute( + code: String?, + viewModel: SetupViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(code) { + viewModel.getAuthToken(code) + } + + 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)) + } + } + } + + SetupScreen(state = state) +} 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..0549313 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupScreen.kt @@ -0,0 +1,64 @@ +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.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun SetupScreen( + state: SetupState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (state.setupStep) { + SetupStep.FETCHING_TOKEN -> { + Text(text = "Connecting to GitHub...") + } + + SetupStep.SAVING_TOKEN -> { + Text(text = "Saving user information...") + } + + SetupStep.SUCCESS -> { + Text(text = "Connected successfully!") + Button( + onClick = { /* TODO: Navigate to the next screen */ }, + ) { + Text(text = "Continue") + } + } + + SetupStep.FAILED -> { + Text(text = "Connection Failed. Please try again.") + } + } + } +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun SetupScreenPreview() { + GitHubNotifierTheme { + Scaffold { innerPadding -> + 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..a4faf00 --- /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 access token from GitHub. */ + FETCHING_TOKEN, + + /** Access 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/setup/SetupViewModel.kt b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt new file mode 100644 index 0000000..00f6c41 --- /dev/null +++ b/app/src/main/java/com/notifier/app/auth/presentation/setup/SetupViewModel.kt @@ -0,0 +1,85 @@ +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 +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 +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 SetupViewModel @Inject constructor( + private val authTokenDataSource: AuthTokenDataSource, + private val dataStoreManager: DataStoreManager, +) : ViewModel() { + + private val _state = MutableStateFlow(SetupState()) + val state = _state + .stateIn( + 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) } + return + } + + _state.update { it.copy(setupStep = SetupStep.FETCHING_TOKEN) } + + viewModelScope.launch { + authTokenDataSource.getAuthToken( + clientId = BuildConfig.CLIENT_ID, + clientSecret = BuildConfig.CLIENT_SECRET, + code = code + ).onSuccess { authToken -> + _state.update { + it.copy( + setupStep = SetupStep.SAVING_TOKEN, + 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 -> + _state.update { it.copy(setupStep = SetupStep.FAILED) } + _events.send(SetupEvent.PersistenceErrorEvent(error as PersistenceError)) + } + } + } +} 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()) +} 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..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 @@ -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,36 +13,29 @@ 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 } 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/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..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/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 d9719f0..f57a1d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ 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. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f8322eb..f4d99a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,9 @@ 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" [libraries] @@ -31,6 +33,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" } @@ -41,9 +44,10 @@ 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] android-application = { id = "com.android.application", version.ref = "agp" } @@ -51,7 +55,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 = [