diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3b30c8..27e9b05 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,21 +65,33 @@ dependencies { debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -// Hilt + // Hilt implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) -// Splash screen + // Splash screen implementation(libs.androidx.core.splashscreen) -// Firebase + // Firebase implementation(platform(libs.firebase.bom)) -// Crashlytics + // Crashlytics implementation(libs.firebase.crashlytics) implementation(libs.firebase.analytics) + // MockK + testImplementation(libs.mockk) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + + // Turbine + testImplementation(libs.turbine) + + // Coroutines test + testImplementation(libs.kotlinx.coroutines.test) + implementation(project(":common")) implementation(project(":scaffold")) implementation(project(":localData")) + implementation(project(":auth")) } \ No newline at end of file diff --git a/app/src/main/java/eu/project/sia/ApplicationStartupState.kt b/app/src/main/java/eu/project/sia/ApplicationStartupState.kt new file mode 100644 index 0000000..c659ddc --- /dev/null +++ b/app/src/main/java/eu/project/sia/ApplicationStartupState.kt @@ -0,0 +1,12 @@ +package eu.project.sia + +import eu.project.common.navigation.Navigation + +/** + * Sealed class representing the initialization state of the application. Contains Pending state (shown during splash + * screen) and Ready state (contains the determined start route for navigation). + */ +internal sealed class ApplicationStartupState { + object Pending: ApplicationStartupState() + data class Ready(val startRoute: Navigation): ApplicationStartupState() +} \ No newline at end of file diff --git a/app/src/main/java/eu/project/sia/ApplicationViewModel.kt b/app/src/main/java/eu/project/sia/ApplicationViewModel.kt new file mode 100644 index 0000000..618e54e --- /dev/null +++ b/app/src/main/java/eu/project/sia/ApplicationViewModel.kt @@ -0,0 +1,56 @@ +package eu.project.sia + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.project.auth.authn.AuthnManager +import eu.project.common.navigation.Navigation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Manages application startup logic. Attempts to restore user session via AuthnManager, determines authentication + * status, and sets the appropriate start destination (authenticated/unauthenticated route or initialization error screen). + */ +@HiltViewModel +internal class ApplicationViewModel @Inject constructor( + private val authnManager: AuthnManager +): ViewModel() { + + private var _applicationStartupState = + MutableStateFlow(ApplicationStartupState.Pending) + + val applicationStartupState = _applicationStartupState.asStateFlow() + + init { setStartupState() } + + private fun setStartupState() { + viewModelScope.launch { + + runCatching { + authnManager.restoreSession() + + val isSignedIn = authnManager.isSignedIn() + + val startDestination = when(isSignedIn) { + true -> Navigation.Authenticated.RouteAuthenticated + false -> Navigation.Unauthenticated.RouteUnauthenticated + } + + _applicationStartupState.update { + ApplicationStartupState.Ready(startDestination) + } + } + .onFailure { + _applicationStartupState.update { + ApplicationStartupState.Ready( + Navigation.InitializationErrorScreen + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/project/sia/MainActivity.kt b/app/src/main/java/eu/project/sia/MainActivity.kt index 5a20023..7193ac5 100644 --- a/app/src/main/java/eu/project/sia/MainActivity.kt +++ b/app/src/main/java/eu/project/sia/MainActivity.kt @@ -5,8 +5,11 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint @@ -14,7 +17,7 @@ import eu.project.common.eventBus.EventBus import eu.project.common.eventBus.SaveFileEventBusQualifier import eu.project.common.eventBus.saveFile.SaveFileEvent import eu.project.common.remoteData.CsvFile -import eu.project.scaffold.applicationScaffold +import eu.project.scaffold.ApplicationScaffold import kotlinx.coroutines.launch import javax.inject.Inject @@ -25,6 +28,8 @@ class MainActivity : ComponentActivity() { @SaveFileEventBusQualifier lateinit var saveFileEventBus: EventBus + private val applicationViewModel: ApplicationViewModel by viewModels() + private var csvFile: CsvFile? = null private val saveFileLauncher = registerForActivityResult(contract = SaveFileContract()) { uri: Uri? -> @@ -54,12 +59,23 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - installSplashScreen() enableEdgeToEdge() + installSplashScreen().setKeepOnScreenCondition { + applicationViewModel.applicationStartupState.value is ApplicationStartupState.Pending + } + setContent { - applicationScaffold() + val applicationStartupState by applicationViewModel.applicationStartupState.collectAsStateWithLifecycle() + + when (applicationStartupState) { + ApplicationStartupState.Pending -> Unit + is ApplicationStartupState.Ready -> { + val startRoute = (applicationStartupState as ApplicationStartupState.Ready).startRoute + ApplicationScaffold(startRoute) + } + } } lifecycleScope.launch { diff --git a/app/src/test/java/eu/project/sia/ApplicationViewModelTest.kt b/app/src/test/java/eu/project/sia/ApplicationViewModelTest.kt new file mode 100644 index 0000000..f836afe --- /dev/null +++ b/app/src/test/java/eu/project/sia/ApplicationViewModelTest.kt @@ -0,0 +1,123 @@ +package eu.project.sia + +import app.cash.turbine.test +import eu.project.auth.authn.AuthnManager +import eu.project.common.navigation.Navigation +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainDispatcherRule( + val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} + +class ApplicationViewModelTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val authnManager = mockk(relaxed = true) + + private lateinit var viewModel: ApplicationViewModel + + + + @Test + fun `sets Ready with RouteAuthenticated when user is signed in`() = runTest { + // set up + coEvery { authnManager.restoreSession() } just Runs + coEvery { authnManager.isSignedIn() } returns true + + // call + viewModel = ApplicationViewModel(authnManager) + + // verify + viewModel.applicationStartupState.test { + assertEquals( + ApplicationStartupState.Ready(Navigation.Authenticated.RouteAuthenticated), + awaitItem() + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `sets Ready with RouteUnauthenticated when user is not signed in`() = runTest { + // set up + coEvery { authnManager.restoreSession() } just Runs + coEvery { authnManager.isSignedIn() } returns false + + // call + viewModel = ApplicationViewModel(authnManager) + + // verify + viewModel.applicationStartupState.test { + assertEquals( + ApplicationStartupState.Ready(Navigation.Unauthenticated.RouteUnauthenticated), + awaitItem() + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `sets Ready with InitializationErrorScreen when restoreSession fails`() = runTest { + // set up + coEvery { authnManager.restoreSession() } throws RuntimeException("Network error") + + // call + viewModel = ApplicationViewModel(authnManager) + + // verify + viewModel.applicationStartupState.test { + assertEquals( + ApplicationStartupState.Ready(Navigation.InitializationErrorScreen), + awaitItem() + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `sets Ready with InitializationErrorScreen when isSignedIn throws`() = runTest { + // set up + coEvery { authnManager.restoreSession() } just Runs + coEvery { authnManager.isSignedIn() } throws IllegalStateException("Corrupted local state") + + // call + viewModel = ApplicationViewModel(authnManager) + + // verify + viewModel.applicationStartupState.test { + assertEquals( + ApplicationStartupState.Ready(Navigation.InitializationErrorScreen), + awaitItem() + ) + cancelAndIgnoreRemainingEvents() + } + } +} \ No newline at end of file diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts new file mode 100644 index 0000000..1dcab30 --- /dev/null +++ b/auth/build.gradle.kts @@ -0,0 +1,93 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "eu.project.auth" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + val googleWebClientId = gradleLocalProperties(rootDir, providers) + .getProperty("GOOGLE_WEB_CLIENT_ID") ?: "\"unknown google web client id\"" + + val supabaseDatabaseUrl = gradleLocalProperties(rootDir, providers) + .getProperty("SUPABASE_DATABASE_URL") ?: "\"https://example.com\"" + + val supabasePublishableKey = gradleLocalProperties(rootDir, providers) + .getProperty("SUPABASE_PUBLISHABLE_KEY") ?: "\"unknown supabase publishable key\"" + + buildTypes { + debug { + isMinifyEnabled = false + + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", googleWebClientId) + buildConfigField("String", "SUPABASE_DATABASE_URL", supabaseDatabaseUrl) + buildConfigField("String", "SUPABASE_PUBLISHABLE_KEY", supabasePublishableKey) + } + release { + isMinifyEnabled = false + + buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", googleWebClientId) + buildConfigField("String", "SUPABASE_DATABASE_URL", supabaseDatabaseUrl) + buildConfigField("String", "SUPABASE_PUBLISHABLE_KEY", supabasePublishableKey) + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + + // Ktor + implementation(libs.ktor.client.android) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Credential Manager + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services.auth) + implementation(libs.googleid) + + // Supabase + implementation(platform(libs.bom)) + implementation(libs.postgrest.kt) + implementation(libs.gotrue.kt) + + implementation(project(":common")) +} \ No newline at end of file diff --git a/auth/consumer-rules.pro b/auth/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/auth/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/auth/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt new file mode 100644 index 0000000..d29fb15 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt @@ -0,0 +1,21 @@ +package eu.project.auth.authn + +import eu.project.auth.result.SignInWithGoogleResult +import eu.project.auth.user.User + +/** + * Handles user authentication and session management. Provides methods to sign in with Google, + * restore existing sessions, check sign-in status, and sign out. + */ +interface AuthnManager { + + suspend fun signInWithGoogle(): SignInWithGoogleResult + + fun getUser(): User? + + suspend fun restoreSession() + + suspend fun isSignedIn(): Boolean + + suspend fun signOut() +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt new file mode 100644 index 0000000..b4f6629 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt @@ -0,0 +1,83 @@ +package eu.project.auth.authn + +import androidx.credentials.exceptions.GetCredentialCancellationException +import io.github.jan.supabase.auth.auth +import io.github.jan.supabase.auth.providers.Google +import io.github.jan.supabase.auth.providers.builtin.IDToken +import eu.project.auth.client.SupabaseClient +import eu.project.auth.credentialManager.GoogleCredentialManager +import eu.project.auth.nonce.NonceGenerator +import eu.project.auth.result.SignInWithGoogleResult +import eu.project.auth.user.User +import io.github.jan.supabase.auth.SignOutScope +import java.util.UUID +import javax.inject.Inject + +/** + * Implementation of `AuthnManager` that uses `SupabaseClient` to perform auth-related tasks. + */ +internal class AuthnManagerImpl @Inject constructor( + supabaseClient: SupabaseClient, + private val googleCredentialManager: GoogleCredentialManager +): AuthnManager { + + private val client = supabaseClient.client + + override suspend fun signInWithGoogle(): SignInWithGoogleResult { + return try { + val nonceSet = NonceGenerator.generateNonce() + + val googleIdToken = googleCredentialManager.getGoogleIdToken(nonceSet.hashedNonce) + + client.auth.signInWith(IDToken) { + this.idToken = googleIdToken.value + this.provider = Google + this.nonce = nonceSet.rawNonce.value + } + SignInWithGoogleResult.Success + } + catch (e: Exception) { + when (e) { + is GetCredentialCancellationException -> SignInWithGoogleResult.Failure.Cancelled + else -> SignInWithGoogleResult.Failure.Unknown(e) + } + } + } + + override fun getUser(): User? { + val id = client.auth.currentUserOrNull()?.id ?: return null + val email = client.auth.currentUserOrNull()?.email ?: return null + + return try { + User( + id = UUID.fromString(id), + email = email + ) + } + catch (e: IllegalArgumentException) { + null + } + } + + override suspend fun restoreSession() { + + client.auth.currentUserOrNull()?.id + + // block the current coroutine until the plugin is initialized. + // ensure the SessionStatus is set to Authenticated, NotAuthenticated or RefreshError + client.auth.awaitInitialization() + } + + override suspend fun isSignedIn(): Boolean { + + // try to retrieve the current session + val session = client.auth.currentSessionOrNull() + return session?.user != null + } + + override suspend fun signOut() { + + // sign out only from the device the app is running on + client.auth.signOut(scope = SignOutScope.LOCAL) + } +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/client/SupabaseClient.kt b/auth/src/main/java/eu/project/auth/client/SupabaseClient.kt new file mode 100644 index 0000000..7b82ddb --- /dev/null +++ b/auth/src/main/java/eu/project/auth/client/SupabaseClient.kt @@ -0,0 +1,23 @@ +package eu.project.auth.client + +import eu.project.auth.BuildConfig +import io.github.jan.supabase.createSupabaseClient +import javax.inject.Inject +import io.github.jan.supabase.auth.Auth + +/** + * Configures and provides the Supabase Kotlin SDK client with authentication support. + */ +internal class SupabaseClient @Inject constructor() { + + private val supabaseDatabaseUrl = BuildConfig.SUPABASE_DATABASE_URL + private val supabasePublishableKey = BuildConfig.SUPABASE_PUBLISHABLE_KEY + + val client = createSupabaseClient( + supabaseUrl = supabaseDatabaseUrl, + supabaseKey = supabasePublishableKey, + builder = { + install(Auth.Companion) + } + ) +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManager.kt b/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManager.kt new file mode 100644 index 0000000..9e530d3 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManager.kt @@ -0,0 +1,12 @@ +package eu.project.auth.credentialManager + +import eu.project.auth.nonce.HashedNonce +import eu.project.auth.token.GoogleIdToken + +/** + * Retrieves a Google ID token for authentication, using the provided hashed nonce for security validation. + */ +interface GoogleCredentialManager { + + suspend fun getGoogleIdToken(hashedNonce: HashedNonce): GoogleIdToken +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManagerImpl.kt b/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManagerImpl.kt new file mode 100644 index 0000000..9eb0246 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManagerImpl.kt @@ -0,0 +1,62 @@ +package eu.project.auth.credentialManager + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import eu.project.auth.BuildConfig +import eu.project.auth.nonce.HashedNonce +import eu.project.auth.token.GoogleIdToken +import javax.inject.Inject + +/** + * Implementation of GoogleCredentialManager. + */ +internal class GoogleCredentialManagerImpl @Inject constructor(private val context: Context): GoogleCredentialManager { + + private val credentialManager = CredentialManager.create(this.context) + private val googleWebClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID + + private fun generateGoogleIdOption(hashedNonce: HashedNonce): GetGoogleIdOption { + return GetGoogleIdOption.Builder() + + // check if the user has any accounts that previously have been used to sign in to the app (must be set to true) + .setFilterByAuthorizedAccounts(false) + + // let the authentication server verify the application that's using the API + .setServerClientId(googleWebClientId) + + // sign in automatically if the user: + // - hasn't signed out + // - there's only one Google account + // - the user hasn't disabled automatic sign-in in their Google Account Settings + .setAutoSelectEnabled(true) + + // prevent replay attacks + .setNonce(hashedNonce.value) + .build() + } + + private fun generateGetCredentialRequest(googleIdOption: GetGoogleIdOption): GetCredentialRequest { + return GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + } + + override suspend fun getGoogleIdToken(hashedNonce: HashedNonce): GoogleIdToken { + val googleIdOption = generateGoogleIdOption(hashedNonce) + val request = generateGetCredentialRequest(googleIdOption) + + val result = credentialManager.getCredential( + context = context, + request = request + ) + val credential = result.credential + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + + val rawGoogleIdToken = googleIdTokenCredential.idToken + val googleIdToken = GoogleIdToken(rawGoogleIdToken) + return googleIdToken + } +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/module/AuthModule.kt b/auth/src/main/java/eu/project/auth/module/AuthModule.kt new file mode 100644 index 0000000..a8e19a9 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/module/AuthModule.kt @@ -0,0 +1,37 @@ +package eu.project.auth.module + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import eu.project.auth.authn.AuthnManager +import eu.project.auth.authn.AuthnManagerImpl +import eu.project.auth.client.SupabaseClient +import eu.project.auth.credentialManager.GoogleCredentialManager +import eu.project.auth.credentialManager.GoogleCredentialManagerImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal class AuthModule { + + @Provides + @Singleton + fun provideAuthManager( + supabaseClient: SupabaseClient, + googleCredentialManager: GoogleCredentialManager + ): AuthnManager = + AuthnManagerImpl( + supabaseClient = supabaseClient, + googleCredentialManager = googleCredentialManager + ) + + @Provides + @Singleton + fun provideGoogleCredentialManager( + @ApplicationContext context: Context, + ): GoogleCredentialManager = + GoogleCredentialManagerImpl(context = context) +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/nonce/HashedNonce.kt b/auth/src/main/java/eu/project/auth/nonce/HashedNonce.kt new file mode 100644 index 0000000..a4b40ec --- /dev/null +++ b/auth/src/main/java/eu/project/auth/nonce/HashedNonce.kt @@ -0,0 +1,3 @@ +package eu.project.auth.nonce + +data class HashedNonce(val value: String) diff --git a/auth/src/main/java/eu/project/auth/nonce/NonceGenerator.kt b/auth/src/main/java/eu/project/auth/nonce/NonceGenerator.kt new file mode 100644 index 0000000..881992f --- /dev/null +++ b/auth/src/main/java/eu/project/auth/nonce/NonceGenerator.kt @@ -0,0 +1,29 @@ +package eu.project.auth.nonce + +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Generates both hashed and raw versions of nonce wrapped by `NonceSet`. + */ +object NonceGenerator { + + fun generateNonce(): NonceSet { + + // create a base to generate a random value + val randomBytes = ByteArray(32) + SecureRandom().nextBytes(randomBytes) + + // create raw nonce + val rawNonce: String = randomBytes.joinToString("") { "%02x".format(it) } + + // create hashed nonce + val digest = MessageDigest.getInstance("SHA-256").digest(rawNonce.toByteArray()) + val hashedNonce: String = digest.joinToString("") { "%02x".format(it) } + + return NonceSet( + rawNonce = RawNonce(value = rawNonce), + hashedNonce = HashedNonce(value = hashedNonce) + ) + } +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/nonce/NonceSet.kt b/auth/src/main/java/eu/project/auth/nonce/NonceSet.kt new file mode 100644 index 0000000..9afc0da --- /dev/null +++ b/auth/src/main/java/eu/project/auth/nonce/NonceSet.kt @@ -0,0 +1,9 @@ +package eu.project.auth.nonce + +/** + * Wrapper of the data classes `RawNonce` and `HashedNonce`. + */ +data class NonceSet( + val rawNonce: RawNonce, + val hashedNonce: HashedNonce +) \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/nonce/RawNonce.kt b/auth/src/main/java/eu/project/auth/nonce/RawNonce.kt new file mode 100644 index 0000000..9b98ed1 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/nonce/RawNonce.kt @@ -0,0 +1,3 @@ +package eu.project.auth.nonce + +data class RawNonce(val value: String) \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/result/SignInWithGoogleResult.kt b/auth/src/main/java/eu/project/auth/result/SignInWithGoogleResult.kt new file mode 100644 index 0000000..f74f4ec --- /dev/null +++ b/auth/src/main/java/eu/project/auth/result/SignInWithGoogleResult.kt @@ -0,0 +1,9 @@ +package eu.project.auth.result + +sealed interface SignInWithGoogleResult { + data object Success: SignInWithGoogleResult + sealed interface Failure: SignInWithGoogleResult { + data object Cancelled: Failure + data class Unknown(val throwable: Throwable): Failure + } +} \ No newline at end of file diff --git a/auth/src/main/java/eu/project/auth/token/AccessToken.kt b/auth/src/main/java/eu/project/auth/token/AccessToken.kt new file mode 100644 index 0000000..f046e32 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/token/AccessToken.kt @@ -0,0 +1,6 @@ +package eu.project.auth.token + +/** + * Holds access tokens generated by `AuthzManager` + */ +data class AccessToken(val value: String) diff --git a/auth/src/main/java/eu/project/auth/token/GoogleIdToken.kt b/auth/src/main/java/eu/project/auth/token/GoogleIdToken.kt new file mode 100644 index 0000000..8e00829 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/token/GoogleIdToken.kt @@ -0,0 +1,6 @@ +package eu.project.auth.token + +/** + * Holds id token generated by `GoogleCredentialManager` + */ +data class GoogleIdToken(val value: String) diff --git a/auth/src/main/java/eu/project/auth/user/User.kt b/auth/src/main/java/eu/project/auth/user/User.kt new file mode 100644 index 0000000..fdb4265 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/user/User.kt @@ -0,0 +1,8 @@ +package eu.project.auth.user + +import java.util.UUID + +data class User( + val id: UUID, + val email: String +) \ No newline at end of file diff --git a/common/src/main/java/eu/project/common/navigation/Navigation.kt b/common/src/main/java/eu/project/common/navigation/Navigation.kt index f017c89..372b68f 100644 --- a/common/src/main/java/eu/project/common/navigation/Navigation.kt +++ b/common/src/main/java/eu/project/common/navigation/Navigation.kt @@ -4,8 +4,26 @@ import kotlinx.serialization.Serializable sealed class Navigation { + sealed class Unauthenticated: Navigation() { + @Serializable + data object RouteUnauthenticated: Unauthenticated() + + @Serializable + data object WelcomeScreen: Unauthenticated() + } + + sealed class Authenticated: Navigation() { + @Serializable + data object RouteAuthenticated: Authenticated() + + @Serializable + data object HomeScreen: Authenticated() + } + @Serializable - data object HomeScreen: Navigation() + data object InitializationErrorScreen: Navigation() + + sealed class Saved: Navigation() { diff --git a/feature/authenticate/.gitignore b/feature/authenticate/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/authenticate/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/authenticate/build.gradle.kts b/feature/authenticate/build.gradle.kts new file mode 100644 index 0000000..fb95191 --- /dev/null +++ b/feature/authenticate/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "eu.project.authenticate" + compileSdk { + version = release(36) + } + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.kotlinVersion.get() + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + + // Navigation + implementation(libs.navigation.compose) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // HiltViewModel + implementation(libs.androidx.hilt.navigation.compose) + + // MockK + testImplementation(libs.mockk) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + + // Turbine + testImplementation(libs.turbine) + + // Coroutines test + testImplementation(libs.kotlinx.coroutines.test) + + // Testing Compose + androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.ui.test.manifest) + + implementation(project(":ui")) + implementation(project(":common")) + implementation(project(":auth")) +} \ No newline at end of file diff --git a/feature/authenticate/consumer-rules.pro b/feature/authenticate/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/authenticate/proguard-rules.pro b/feature/authenticate/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/authenticate/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/authenticate/src/androidTest/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreenTest.kt b/feature/authenticate/src/androidTest/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreenTest.kt new file mode 100644 index 0000000..ce22caf --- /dev/null +++ b/feature/authenticate/src/androidTest/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreenTest.kt @@ -0,0 +1,124 @@ +package eu.project.authenticate.welcomeScreen.screen + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import eu.project.authenticate.welcomeScreen.intent.WelcomeScreenIntent +import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState +import eu.project.design_system.TestTag +import eu.project.ui.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class WelcomeScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private var capturedIntent: WelcomeScreenIntent? = null + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + + +//- WelcomeScreenState.Idle Tests ------------------------------------------------------------------ + + @Test + fun welcomeScreen_idleState_displaysIdleContent() { + setupWelcomeScreen(WelcomeScreenState.Idle) + + composeTestRule + .onNodeWithTag(TestTag.WelcomeScreen.IDLE_CONTENT) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.welcome_screen___sia)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.welcome_screen___sia_explanation)) + .assertIsDisplayed() + } + + @Test + fun welcomeScreen_idleState_continueWithGoogleClick_triggersIntent() { + setupWelcomeScreen(WelcomeScreenState.Idle) + + composeTestRule + .onNodeWithTag(TestTag.WelcomeScreen.IDLE_CONTENT_BUTTON) + .performClick() + + assertEquals(WelcomeScreenIntent.ClickContinueWithGoogle, capturedIntent) + } + + + +//- WelcomeScreenState.Idle Pending & Success Tests ------------------------------------------------ + + @Test + fun welcomeScreen_pendingState_displaysLoadingContent() { + setupWelcomeScreen(WelcomeScreenState.Pending) + + composeTestRule + .onNodeWithTag(TestTag.WelcomeScreen.LOADING_CONTENT) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.welcome_screen___loading)) + .assertIsDisplayed() + } + + @Test + fun welcomeScreen_successState_displaysLoadingContent() { + setupWelcomeScreen(WelcomeScreenState.Success) + + composeTestRule + .onNodeWithTag(TestTag.WelcomeScreen.LOADING_CONTENT) + .assertIsDisplayed() + } + + + +//- Isolated Tests --------------------------------------------------------------------------------- + + @Test + fun welcomeScreenIdleContent_displaysSecondaryText() { + var clicked = false + composeTestRule.setContent { + eu.project.authenticate.welcomeScreen.screen.content.WelcomeScreenIdleContent( + onClickContinueWithGoogle = { clicked = true } + ) + } + + composeTestRule + .onNodeWithText(context.getString(R.string.welcome_screen___sia)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText(context.getString(R.string.welcome_screen___sia_explanation)) + .assertIsDisplayed() + + composeTestRule + .onNodeWithTag(TestTag.WelcomeScreen.IDLE_CONTENT_BUTTON) + .performClick() + + assertTrue(clicked) + } + + + +//- Helper-------- --------------------------------------------------------------------------------- + + private fun setupWelcomeScreen(state: WelcomeScreenState) { + composeTestRule.setContent { + WelcomeScreen( + state = state, + handleIntent = { intent -> capturedIntent = intent } + ) + } + } +} \ No newline at end of file diff --git a/feature/authenticate/src/main/AndroidManifest.xml b/feature/authenticate/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature/authenticate/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/impl/WelcomeScreenImpl.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/impl/WelcomeScreenImpl.kt new file mode 100644 index 0000000..06b5f97 --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/impl/WelcomeScreenImpl.kt @@ -0,0 +1,36 @@ +package eu.project.authenticate.welcomeScreen.impl + +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import eu.project.authenticate.welcomeScreen.screen.WelcomeScreen +import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState +import eu.project.authenticate.welcomeScreen.vm.WelcomeScreenViewModel +import eu.project.common.navigation.Navigation + +fun NavGraphBuilder.welcomeImpl(controller: NavHostController) { + composable { + + val viewModel = hiltViewModel() + val state by viewModel.screenState.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + if (state is WelcomeScreenState.Success) { + controller.navigate(Navigation.Authenticated.RouteAuthenticated) { + popUpTo(Navigation.Unauthenticated.RouteUnauthenticated) { inclusive = true } + } + } + } + + WelcomeScreen( + state = state, + handleIntent = { intent -> + viewModel.handleIntent(intent) + } + ) + } +} \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/intent/WelcomeScreenIntent.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/intent/WelcomeScreenIntent.kt new file mode 100644 index 0000000..f984e32 --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/intent/WelcomeScreenIntent.kt @@ -0,0 +1,5 @@ +package eu.project.authenticate.welcomeScreen.intent + +internal sealed interface WelcomeScreenIntent { + data object ClickContinueWithGoogle: WelcomeScreenIntent +} \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreen.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreen.kt new file mode 100644 index 0000000..43577d1 --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreen.kt @@ -0,0 +1,33 @@ +package eu.project.authenticate.welcomeScreen.screen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.project.authenticate.welcomeScreen.intent.WelcomeScreenIntent +import eu.project.authenticate.welcomeScreen.screen.content.WelcomeScreenIdleContent +import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState +import eu.project.design_system.TestTag +import eu.project.design_system.content.ContentHolder +import eu.project.design_system.content.LoadingContent +import eu.project.ui.R + +@Composable +internal fun WelcomeScreen( + state: WelcomeScreenState, + handleIntent: (WelcomeScreenIntent) -> Unit +) { + ContentHolder(testTag = TestTag.WelcomeScreen.SCREEN) { + when(state) { + WelcomeScreenState.Idle -> { + WelcomeScreenIdleContent { + handleIntent(WelcomeScreenIntent.ClickContinueWithGoogle) + } + } + WelcomeScreenState.Pending, WelcomeScreenState.Success -> { + LoadingContent( + supportingText = stringResource(R.string.welcome_screen___loading), + testTag = TestTag.WelcomeScreen.LOADING_CONTENT + ) + } + } + } +} \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/content/WelcomeScreenIdleContent.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/content/WelcomeScreenIdleContent.kt new file mode 100644 index 0000000..99bd525 --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/content/WelcomeScreenIdleContent.kt @@ -0,0 +1,59 @@ +package eu.project.authenticate.welcomeScreen.screen.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +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.text.style.TextAlign +import eu.project.design_system.TestTag +import eu.project.design_system.component.VerticalSpacer +import eu.project.design_system.component.button.ButtonSize +import eu.project.design_system.component.button.ButtonState +import eu.project.design_system.component.button.FilledButton +import eu.project.design_system.component.button.FilledButtonType +import eu.project.design_system.content.SplitContent +import eu.project.design_system.theme.SiaTheme +import eu.project.design_system.theme.Space +import eu.project.ui.R + +@Composable +internal fun WelcomeScreenIdleContent( + onClickContinueWithGoogle: () -> Unit +) { + SplitContent( + testTag = TestTag.WelcomeScreen.IDLE_CONTENT, + upperVerticalArrangement = Arrangement.Bottom, + lowerVerticalArrangement = Arrangement.Bottom, + upperContent = { + Text( + text = stringResource(R.string.welcome_screen___sia), + color = SiaTheme.color.text.primary, + style = SiaTheme.typography.displayLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(0.85f) + ) + + VerticalSpacer(Space.S4) + + Text( + text = stringResource(R.string.welcome_screen___sia_explanation), + color = SiaTheme.color.text.secondary, + style = SiaTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(0.85f) + ) + }, + lowerContent = { + FilledButton( + onClick = onClickContinueWithGoogle, + label = stringResource(R.string.welcome_screen___continue_with_google), + type = FilledButtonType.Primary, + size = ButtonSize.Medium, + state = ButtonState.Enabled, + testTag = TestTag.WelcomeScreen.IDLE_CONTENT_BUTTON, + ) + } + ) +} \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/state/WelcomeScreenState.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/state/WelcomeScreenState.kt new file mode 100644 index 0000000..76d9dac --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/state/WelcomeScreenState.kt @@ -0,0 +1,7 @@ +package eu.project.authenticate.welcomeScreen.state + +internal sealed interface WelcomeScreenState { + data object Idle: WelcomeScreenState + data object Pending: WelcomeScreenState + data object Success: WelcomeScreenState +} \ No newline at end of file diff --git a/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModel.kt b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModel.kt new file mode 100644 index 0000000..d1e12ca --- /dev/null +++ b/feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModel.kt @@ -0,0 +1,58 @@ +package eu.project.authenticate.welcomeScreen.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.project.auth.authn.AuthnManager +import eu.project.auth.result.SignInWithGoogleResult +import eu.project.authenticate.welcomeScreen.intent.WelcomeScreenIntent +import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState +import eu.project.common.crashlytics.CrashlyticsManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +internal class WelcomeScreenViewModel @Inject constructor( + private val authnManager: AuthnManager, + private val crashlyticsManager: CrashlyticsManager +): ViewModel() { + + private val _screenState = MutableStateFlow(WelcomeScreenState.Idle) + val screenState = _screenState.asStateFlow() + + fun handleIntent(intent: WelcomeScreenIntent) { + when (intent) { + WelcomeScreenIntent.ClickContinueWithGoogle -> handleContinueWithGoogle() + } + } + + private fun handleContinueWithGoogle() { + viewModelScope.launch { + + _screenState.update { WelcomeScreenState.Pending } + + val result = withContext(Dispatchers.IO) { + authnManager.signInWithGoogle() + } + + val state = when(result) { + SignInWithGoogleResult.Success -> { + WelcomeScreenState.Success + } + SignInWithGoogleResult.Failure.Cancelled -> { + WelcomeScreenState.Idle + } + is SignInWithGoogleResult.Failure.Unknown -> { + crashlyticsManager.recordException(result.throwable) + WelcomeScreenState.Idle + } + } + _screenState.update { state } + } + } +} \ No newline at end of file diff --git a/feature/authenticate/src/test/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModelTest.kt b/feature/authenticate/src/test/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModelTest.kt new file mode 100644 index 0000000..948cc87 --- /dev/null +++ b/feature/authenticate/src/test/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModelTest.kt @@ -0,0 +1,131 @@ +package eu.project.authenticate.welcomeScreen.vm + +import app.cash.turbine.test +import eu.project.auth.authn.AuthnManager +import eu.project.auth.result.SignInWithGoogleResult +import eu.project.authenticate.welcomeScreen.intent.WelcomeScreenIntent +import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState +import eu.project.common.crashlytics.CrashlyticsManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class WelcomeScreenViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val authnManager = mockk(relaxed = true) + private val crashlyticsManager = mockk(relaxed = true) + + private lateinit var viewModel: WelcomeScreenViewModel + + @Before + fun setup() { + viewModel = WelcomeScreenViewModel(authnManager, crashlyticsManager) + } + + @Test + fun `initial state is Idle`() = runTest { + viewModel.screenState.test { + assertEquals(WelcomeScreenState.Idle, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + confirmVerified() + } + + @Test + fun `ClickContinueWithGoogle sets state to Success when sign in succeeds`() = runTest { + // Prepare mock + coEvery { authnManager.signInWithGoogle() } returns SignInWithGoogleResult.Success + + viewModel.screenState.test { + + skipItems(1) + + // Trigger intent + viewModel.handleIntent(WelcomeScreenIntent.ClickContinueWithGoogle) + + // Verify sequence: Pending -> Success + assertEquals(WelcomeScreenState.Pending, awaitItem()) + assertEquals(WelcomeScreenState.Success, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + coVerify(exactly = 1) { authnManager.signInWithGoogle() } + confirmVerified() + } + + @Test + fun `ClickContinueWithGoogle returns to Idle when sign in is cancelled`() = runTest { + // Prepare mock + coEvery { authnManager.signInWithGoogle() } returns SignInWithGoogleResult.Failure.Cancelled + + viewModel.screenState.test { + assertEquals(WelcomeScreenState.Idle, awaitItem()) + + viewModel.handleIntent(WelcomeScreenIntent.ClickContinueWithGoogle) + + assertEquals(WelcomeScreenState.Pending, awaitItem()) + assertEquals(WelcomeScreenState.Idle, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + coVerify(exactly = 1) { authnManager.signInWithGoogle() } + confirmVerified() + } + + @Test + fun `ClickContinueWithGoogle records exception and returns to Idle on Unknown failure`() = runTest { + // Prepare mock + val testException = RuntimeException("Auth failed") + coEvery { authnManager.signInWithGoogle() } returns SignInWithGoogleResult.Failure.Unknown(testException) + + viewModel.screenState.test { + assertEquals(WelcomeScreenState.Idle, awaitItem()) + + viewModel.handleIntent(WelcomeScreenIntent.ClickContinueWithGoogle) + + assertEquals(WelcomeScreenState.Pending, awaitItem()) + assertEquals(WelcomeScreenState.Idle, awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + coVerify(exactly = 1) { + authnManager.signInWithGoogle() + crashlyticsManager.recordException(testException) + } + confirmVerified() + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/eu/project/home/impl/HomeImpl.kt b/feature/home/src/main/java/eu/project/home/impl/HomeImpl.kt index d99f502..7ccd3b2 100644 --- a/feature/home/src/main/java/eu/project/home/impl/HomeImpl.kt +++ b/feature/home/src/main/java/eu/project/home/impl/HomeImpl.kt @@ -13,7 +13,7 @@ import eu.project.home.screen.homeScreen fun NavGraphBuilder.homeImpl(controller: NavHostController) { - composable { + composable { val viewModel = hiltViewModel() val isNetworkAvailable by viewModel.isNetworkAvailable.collectAsStateWithLifecycle() diff --git a/feature/saved/src/main/java/eu/project/saved/exportWords/impl/ExportWordsImpl.kt b/feature/saved/src/main/java/eu/project/saved/exportWords/impl/ExportWordsImpl.kt index c653295..5bbf630 100644 --- a/feature/saved/src/main/java/eu/project/saved/exportWords/impl/ExportWordsImpl.kt +++ b/feature/saved/src/main/java/eu/project/saved/exportWords/impl/ExportWordsImpl.kt @@ -34,7 +34,7 @@ fun NavGraphBuilder.exportWordsImpl(controller: NavHostController) { controller.navigate(ExportResultScreen(exportSettingsSerialized = exportSettingsSerialized)) { - this.popUpTo { inclusive = false } + this.popUpTo { inclusive = false } } } ) diff --git a/feature/saved/src/main/java/eu/project/saved/savedWords/impl/SavedWordsImpl.kt b/feature/saved/src/main/java/eu/project/saved/savedWords/impl/SavedWordsImpl.kt index 48e8d7d..69182f9 100644 --- a/feature/saved/src/main/java/eu/project/saved/savedWords/impl/SavedWordsImpl.kt +++ b/feature/saved/src/main/java/eu/project/saved/savedWords/impl/SavedWordsImpl.kt @@ -28,7 +28,7 @@ fun NavGraphBuilder.savedWordsImpl(controller: NavHostController) { controller.navigate(Navigation.Transcribe.SelectAudioScreen) { - this.popUpTo(Navigation.HomeScreen) { inclusive = false } + this.popUpTo(Navigation.Authenticated.HomeScreen) { inclusive = false } } } ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f8256c..708da36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,11 @@ turbineVersion = "1.2.1" uiTestJunit4Version = "1.8.3" uiTestManifestVersion = "1.8.3" ksp = "2.2.21-2.0.4" +ktorClientAndroid = "3.3.1" +credentialsPlayServicesAuth = "1.6.0-beta02" +googleid = "1.1.1" +supabaseBom = "3.2.5" +gotrueKt = "2.7.0-beta-1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityComposeVersion" } @@ -80,6 +85,14 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbineVersion" ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiTestManifestVersion" } ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiTestJunit4Version" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" } +googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentialsPlayServicesAuth" } +bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "supabaseBom" } +postgrest-kt = { module = "io.github.jan-tennert.supabase:postgrest-kt" } +gotrue-kt = { module = "io.github.jan-tennert.supabase:gotrue-kt", version.ref = "gotrueKt" } + [plugins] android-library = { id = "com.android.library", version.ref = "agpVersion" } diff --git a/scaffold/build.gradle.kts b/scaffold/build.gradle.kts index 56eba3e..caf424b 100644 --- a/scaffold/build.gradle.kts +++ b/scaffold/build.gradle.kts @@ -93,4 +93,5 @@ dependencies { implementation(project(":feature:home")) implementation(project(":feature:saved")) implementation(project(":feature:transcribe")) + implementation(project(":feature:authenticate")) } \ No newline at end of file diff --git a/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt b/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt index 8748c38..54d054e 100644 --- a/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt +++ b/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt @@ -10,8 +10,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController +import eu.project.authenticate.welcomeScreen.impl.welcomeImpl import eu.project.common.navigation.Navigation import eu.project.floatingActionButton.impl.floatingActionButtonImpl import eu.project.home.impl.homeImpl @@ -24,7 +26,7 @@ import eu.project.ui.theme.Background @OptIn(ExperimentalLayoutApi::class) @Composable -fun applicationScaffold() { +fun ApplicationScaffold(startRoute: Navigation) { val controller = rememberNavController() @@ -35,11 +37,9 @@ fun applicationScaffold() { contentWindowInsets = WindowInsets.statusBars, floatingActionButtonPosition = FabPosition.Center, topBar = { - topBarImpl(controller) }, floatingActionButton = { - floatingActionButtonImpl(controller) }, content = { paddingValues -> @@ -49,10 +49,30 @@ fun applicationScaffold() { top = paddingValues.calculateTopPadding() ), navController = controller, - startDestination = Navigation.HomeScreen, + startDestination = startRoute, builder = { - this.homeImpl(controller) + this.navigation( + startDestination = Navigation.Unauthenticated.WelcomeScreen + ) { + this.welcomeImpl(controller) + } + + this.navigation( + startDestination = Navigation.Authenticated.HomeScreen + ) { + this.homeImpl(controller) + } + + this.composable {} + + + + + + + + this.navigation(startDestination = Navigation.Saved.SavedWordsScreen) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 67a4964..10ea37c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,6 @@ include(":feature:home") include(":feature:saved") include(":feature:transcribe") include(":localData") -include(":remoteData") \ No newline at end of file +include(":remoteData") +include(":auth") +include(":feature:authenticate") diff --git a/ui/src/main/java/eu/project/design_system/TestTag.kt b/ui/src/main/java/eu/project/design_system/TestTag.kt index f2263b0..1d1edca 100644 --- a/ui/src/main/java/eu/project/design_system/TestTag.kt +++ b/ui/src/main/java/eu/project/design_system/TestTag.kt @@ -50,4 +50,13 @@ object TestTag { fun dialogConfirmButton(parentTag: String) = "${parentTag}_confirm_text_button" fun dialogDismissButton(parentTag: String) = "${parentTag}_dismiss_text_button" } + + object WelcomeScreen { + const val SCREEN = "welcome_screen" + + const val IDLE_CONTENT = "welcome_screen_idle_content" + const val IDLE_CONTENT_BUTTON = "welcome_screen_idle_content_button" + + const val LOADING_CONTENT = "welcome_screen_loading_content" + } } \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/content/ContentHolder.kt b/ui/src/main/java/eu/project/design_system/content/ContentHolder.kt new file mode 100644 index 0000000..25dbda8 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/content/ContentHolder.kt @@ -0,0 +1,24 @@ +package eu.project.design_system.content + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import eu.project.design_system.theme.SiaTheme + +@Composable +fun ContentHolder( + testTag: String, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(SiaTheme.color.surface.background) + .testTag(testTag) + ) { + content() + } +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/content/LoadingContent.kt b/ui/src/main/java/eu/project/design_system/content/LoadingContent.kt new file mode 100644 index 0000000..9acbb91 --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/content/LoadingContent.kt @@ -0,0 +1,36 @@ +package eu.project.design_system.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import eu.project.design_system.TestTag +import eu.project.design_system.component.LoadingIndicator +import eu.project.design_system.component.VerticalSpacer +import eu.project.design_system.theme.SiaTheme +import eu.project.design_system.theme.Space + +@Composable +fun LoadingContent( + supportingText: String, + testTag: String +) { + SplitContent( + upperVerticalArrangement = Arrangement.Bottom, + lowerVerticalArrangement = Arrangement.Bottom, + testTag = testTag, + upperContent = { + LoadingIndicator( + testTag = TestTag.Component.loadingIndicator(testTag) + ) + + VerticalSpacer(Space.S4) + + Text( + text = supportingText, + style = SiaTheme.typography.bodyMedium, + color = SiaTheme.color.text.secondary + ) + }, + lowerContent = {} + ) +} \ No newline at end of file diff --git a/ui/src/main/java/eu/project/design_system/content/SplitContent.kt b/ui/src/main/java/eu/project/design_system/content/SplitContent.kt new file mode 100644 index 0000000..d65038c --- /dev/null +++ b/ui/src/main/java/eu/project/design_system/content/SplitContent.kt @@ -0,0 +1,54 @@ +package eu.project.design_system.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import eu.project.design_system.theme.Space + +@Composable +fun SplitContent( + testTag: String, + upperVerticalArrangement: Arrangement.Vertical, + lowerVerticalArrangement: Arrangement.Vertical, + upperContent: @Composable () -> Unit, + lowerContent: @Composable () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .testTag(testTag), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = Space.S16.value, + vertical = Space.S24.value + ) + .weight(1f), + verticalArrangement = upperVerticalArrangement, + horizontalAlignment = Alignment.CenterHorizontally + ) { + upperContent() + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = Space.S16.value, + vertical = Space.S24.value + ) + .weight(1f), + verticalArrangement = lowerVerticalArrangement, + horizontalAlignment = Alignment.CenterHorizontally + ) { + lowerContent() + } + } +} \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 7af03d7..58edb7b 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1,6 +1,12 @@ Say It Again + + Sia + acro. \"Say it again" + Continue with Google + You are being signed in. + Your are offline. Transcriptions can’t be generated while offline.