Skip to content

Fix #12, #16, #18: Add GitHub OAuth, error handling, and navigation with Jetpack Compose #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: base-sha/c5294370e3aeff90077e8c2c1b8c595cf0316833
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
463e490
Add LoginButton composable and integrate it into MainActivity
theMr17 Apr 27, 2025
d084a29
Add CLIENT_ID and CLIENT_SECRET to buildConfig in debug and release b…
theMr17 Apr 27, 2025
ed82b5a
Add GitHub OAuth integration with LoginButton and createGitHubAuthIntent
theMr17 Apr 27, 2025
7058ed3
Add LoginScreen composable and update MainActivity to use it
theMr17 Apr 27, 2025
bbedd44
Add authentication feature with ViewModel, data sources, and error ha…
theMr17 Apr 28, 2025
1b7b3b5
Add ConnectingToGitHubScreen and navigation setup in MainActivity
theMr17 Apr 28, 2025
9c6c7ac
Update build.gradle.kts to handle missing local.properties gracefully…
theMr17 Apr 28, 2025
2cda0cc
Add GitHub connection handling with state management and navigation s…
theMr17 Apr 29, 2025
2bb8b02
Rename authentication-related classes and update references to improv…
theMr17 Apr 29, 2025
b710a5e
Update SetupStep enum documentation to clarify token retrieval proces…
theMr17 Apr 29, 2025
99577cd
Merge branch 'main' into feat/setup-authentication-flow
theMr17 May 1, 2025
6e396b2
Fix daggerHilt variable name
theMr17 May 1, 2025
bbb0150
Refactor error handling in DataStore operations and update related cl…
theMr17 May 1, 2025
0d604e8
Update test script to include verbose output for connectedAndroidTest
theMr17 May 1, 2025
de4f862
Enhance test script to handle emulator termination on test failure
theMr17 May 1, 2025
a34fa01
Revert changes to test.yml
theMr17 May 1, 2025
fb5a0cd
Refactor DataStoreManager tests to assert Result type and improve cla…
theMr17 May 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -28,34 +29,49 @@ 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
fun testSetAccessToken_overwritesExistingToken() = runTest {
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)
}
}
13 changes: 12 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".GithubNotifierApp"
android:icon="@mipmap/ic_launcher"
Expand All @@ -14,9 +16,18 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="auth-callback"
android:scheme="github-notifier" />
</intent-filter>
</activity>
</application>

Expand Down
52 changes: 33 additions & 19 deletions app/src/main/java/com/notifier/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<LoginScreen> {
LoginRoute()
}

composable<SetupScreen>(
deepLinks = listOf(
navDeepLink<SetupScreen>(
basePath = "github-notifier://auth-callback"
)
)
) {
val args = it.toRoute<SetupScreen>()
SetupRoute(code = args.code)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<AuthToken, NetworkError> {
return safeCall<AuthTokenResponseDto> {
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 7 additions & 0 deletions app/src/main/java/com/notifier/app/auth/domain/AuthToken.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.notifier.app.auth.domain

data class AuthToken(
val accessToken: String,
val scope: String,
val tokenType: String,
)
Original file line number Diff line number Diff line change
@@ -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<AuthToken, Error>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.notifier.app.auth.presentation.login

sealed interface LoginAction {
data object OnLoginButtonClick : LoginAction
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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) }
)
}

Loading