From 61862112e03c24fcd2e75d329d2d3f70cb54a23a Mon Sep 17 00:00:00 2001 From: Marcello Date: Fri, 3 Apr 2026 14:41:05 +0200 Subject: [PATCH 01/11] Initialize the `auth` module - Create a new Android library module `auth` with namespace `eu.project.auth`. - Configure `build.gradle.kts` with `compileSdk` 36, `minSdk` 26, and Java 11 compatibility. - Add dependencies for Hilt (using KSP), AppCompat, Material, and the internal `:common` module. - Register the `:auth` module in `settings.gradle.kts`. - Add standard ProGuard and gitignore configurations for the new module. --- auth/.gitignore | 1 + auth/build.gradle.kts | 49 +++++++++++++++++++++++++++++++ auth/consumer-rules.pro | 0 auth/proguard-rules.pro | 21 +++++++++++++ auth/src/main/AndroidManifest.xml | 2 ++ settings.gradle.kts | 3 +- 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 auth/.gitignore create mode 100644 auth/build.gradle.kts create mode 100644 auth/consumer-rules.pro create mode 100644 auth/proguard-rules.pro create mode 100644 auth/src/main/AndroidManifest.xml 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..862b633 --- /dev/null +++ b/auth/build.gradle.kts @@ -0,0 +1,49 @@ +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") + } + + 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" + } +} + +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) + + 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/settings.gradle.kts b/settings.gradle.kts index 67a4964..63aeef5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,5 @@ include(":feature:home") include(":feature:saved") include(":feature:transcribe") include(":localData") -include(":remoteData") \ No newline at end of file +include(":remoteData") +include(":auth") From 202f1c3e01e94f645bc835b52bd17f084a897c5b Mon Sep 17 00:00:00 2001 From: Marcello Date: Fri, 3 Apr 2026 17:57:04 +0200 Subject: [PATCH 02/11] Implement Google Sign-In and Supabase authentication - Add `AuthnManager` and `AuthnManagerImpl` to handle Google authentication and session management via Supabase. - Implement `GoogleCredentialManager` using Android's `CredentialManager` to retrieve Google ID tokens. - Add `NonceGenerator` to create raw and SHA-256 hashed nonces for secure authentication. - Configure `SupabaseClient` with authentication support using GoTrue/Auth. - Add required dependencies for Ktor, Supabase BOM, AndroidX Credentials, and Google ID to `libs.versions.toml` and `auth/build.gradle.kts`. - Enable `buildConfig` and define `GOOGLE_WEB_CLIENT_ID`, `SUPABASE_DATABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` fields in the `auth` module. - Define data classes for `RawNonce`, `HashedNonce`, `NonceSet`, `GoogleIdToken`, and `AccessToken`. --- auth/build.gradle.kts | 44 +++++++++++++ .../eu/project/auth/authn/AuthnManager.kt | 22 +++++++ .../eu/project/auth/authn/AuthnManagerImpl.kt | 51 +++++++++++++++ .../eu/project/auth/client/SupabaseClient.kt | 23 +++++++ .../GoogleCredentialManager.kt | 12 ++++ .../GoogleCredentialManagerImpl.kt | 62 +++++++++++++++++++ .../java/eu/project/auth/module/AuthModule.kt | 33 ++++++++++ .../java/eu/project/auth/nonce/HashedNonce.kt | 3 + .../eu/project/auth/nonce/NonceGenerator.kt | 29 +++++++++ .../java/eu/project/auth/nonce/NonceSet.kt | 9 +++ .../java/eu/project/auth/nonce/RawNonce.kt | 3 + .../java/eu/project/auth/token/AccessToken.kt | 6 ++ .../eu/project/auth/token/GoogleIdToken.kt | 6 ++ gradle/libs.versions.toml | 13 ++++ 14 files changed, 316 insertions(+) create mode 100644 auth/src/main/java/eu/project/auth/authn/AuthnManager.kt create mode 100644 auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt create mode 100644 auth/src/main/java/eu/project/auth/client/SupabaseClient.kt create mode 100644 auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManager.kt create mode 100644 auth/src/main/java/eu/project/auth/credentialManager/GoogleCredentialManagerImpl.kt create mode 100644 auth/src/main/java/eu/project/auth/module/AuthModule.kt create mode 100644 auth/src/main/java/eu/project/auth/nonce/HashedNonce.kt create mode 100644 auth/src/main/java/eu/project/auth/nonce/NonceGenerator.kt create mode 100644 auth/src/main/java/eu/project/auth/nonce/NonceSet.kt create mode 100644 auth/src/main/java/eu/project/auth/nonce/RawNonce.kt create mode 100644 auth/src/main/java/eu/project/auth/token/AccessToken.kt create mode 100644 auth/src/main/java/eu/project/auth/token/GoogleIdToken.kt diff --git a/auth/build.gradle.kts b/auth/build.gradle.kts index 862b633..1dcab30 100644 --- a/auth/build.gradle.kts +++ b/auth/build.gradle.kts @@ -1,3 +1,5 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) @@ -15,15 +17,41 @@ android { 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 @@ -45,5 +73,21 @@ dependencies { 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/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..14f45e6 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt @@ -0,0 +1,22 @@ +package eu.project.auth.authn + +import eu.project.auth.nonce.RawNonce +import eu.project.auth.token.GoogleIdToken + +/** + * 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( + googleIdToken: GoogleIdToken, + rawNonce: RawNonce + ) + + 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..9a15096 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt @@ -0,0 +1,51 @@ +package eu.project.auth.authn + +import eu.project.auth.nonce.RawNonce +import eu.project.auth.token.GoogleIdToken +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 io.github.jan.supabase.auth.SignOutScope +import javax.inject.Inject + +/** + * Implementation of `AuthnManager` that uses `SupabaseClient` to perform auth-related tasks. + */ +internal class AuthnManagerImpl @Inject constructor(supabaseClient: SupabaseClient): AuthnManager { + + private val client = supabaseClient.client + + override suspend fun signInWithGoogle( + googleIdToken: GoogleIdToken, + rawNonce: RawNonce + ) { + + // sign in using Google + client.auth.signInWith(IDToken) { + this.idToken = googleIdToken.value + this.provider = Google + this.nonce = rawNonce.value + } + } + + override suspend fun restoreSession() { + + // 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..59823c6 --- /dev/null +++ b/auth/src/main/java/eu/project/auth/module/AuthModule.kt @@ -0,0 +1,33 @@ +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, + ): AuthnManager = + AuthnManagerImpl(supabaseClient) + + @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/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/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" } From b77ccae9a8a876d75972081fe9634aca89147c7a Mon Sep 17 00:00:00 2001 From: Marcello Date: Fri, 3 Apr 2026 19:04:10 +0200 Subject: [PATCH 03/11] Implement user data model and retrieval in `auth` module - Create `User` data class with `id` (UUID) and `email` fields. - Add `getUser()` method to the `AuthnManager` interface to retrieve the current session user. - Implement `getUser()` in `AuthnManagerImpl` by mapping Supabase current user data to the `User` model. - Add UUID parsing and validation logic when retrieving the user ID from the authentication client. --- .../eu/project/auth/authn/AuthnManager.kt | 3 +++ .../eu/project/auth/authn/AuthnManagerImpl.kt | 19 +++++++++++++++++++ .../main/java/eu/project/auth/user/User.kt | 8 ++++++++ 3 files changed, 30 insertions(+) create mode 100644 auth/src/main/java/eu/project/auth/user/User.kt diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt index 14f45e6..07a0d99 100644 --- a/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt @@ -2,6 +2,7 @@ package eu.project.auth.authn import eu.project.auth.nonce.RawNonce import eu.project.auth.token.GoogleIdToken +import eu.project.auth.user.User /** * Handles user authentication and session management. Provides methods to sign in with Google, @@ -14,6 +15,8 @@ interface AuthnManager { rawNonce: RawNonce ) + fun getUser(): User? + suspend fun restoreSession() suspend fun isSignedIn(): Boolean diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt index 9a15096..d15ac09 100644 --- a/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt @@ -6,7 +6,9 @@ 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.user.User import io.github.jan.supabase.auth.SignOutScope +import java.util.UUID import javax.inject.Inject /** @@ -29,8 +31,25 @@ internal class AuthnManagerImpl @Inject constructor(supabaseClient: SupabaseClie } } + 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() 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 From 557c141d46d1f988e02e9dcdfcaeff63aa7d4a3c Mon Sep 17 00:00:00 2001 From: Marcello Date: Fri, 3 Apr 2026 22:14:35 +0200 Subject: [PATCH 04/11] Implement application startup logic and conditional navigation - Create `ApplicationViewModel` to handle session restoration and determine the initial navigation route using `AuthnManager`. - Introduce `ApplicationStartupState` sealed class to represent `Pending` and `Ready` states. - Update `MainActivity` to keep the splash screen visible until the startup state is `Ready`. - Modify `ApplicationScaffold` to accept a `startRoute` and configure navigation graphs for `Authenticated`, `Unauthenticated`, and `InitializationError` destinations. - Expand `Navigation` sealed class with nested `Authenticated` and `Unauthenticated` route structures. - Add unit tests for `ApplicationViewModel` covering signed-in, signed-out, and initialization error scenarios. --- app/build.gradle.kts | 20 ++- .../eu/project/sia/ApplicationStartupState.kt | 12 ++ .../eu/project/sia/ApplicationViewModel.kt | 56 ++++++++ .../main/java/eu/project/sia/MainActivity.kt | 22 +++- .../project/sia/ApplicationViewModelTest.kt | 123 ++++++++++++++++++ .../project/common/navigation/Navigation.kt | 20 +++ .../project/scaffold/ApplicationScaffold.kt | 34 ++++- 7 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/eu/project/sia/ApplicationStartupState.kt create mode 100644 app/src/main/java/eu/project/sia/ApplicationViewModel.kt create mode 100644 app/src/test/java/eu/project/sia/ApplicationViewModelTest.kt 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/common/src/main/java/eu/project/common/navigation/Navigation.kt b/common/src/main/java/eu/project/common/navigation/Navigation.kt index f017c89..e8844c9 100644 --- a/common/src/main/java/eu/project/common/navigation/Navigation.kt +++ b/common/src/main/java/eu/project/common/navigation/Navigation.kt @@ -4,6 +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 InitializationErrorScreen: Navigation() + + @Serializable data object HomeScreen: Navigation() diff --git a/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt b/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt index 8748c38..1176fd1 100644 --- a/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt +++ b/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt @@ -1,5 +1,7 @@ package eu.project.scaffold +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -10,9 +12,11 @@ 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.common.navigation.Navigation +import eu.project.design_system.theme.SiaTheme import eu.project.floatingActionButton.impl.floatingActionButtonImpl import eu.project.home.impl.homeImpl import eu.project.saved.exportResult.impl.exportResultImpl @@ -24,7 +28,7 @@ import eu.project.ui.theme.Background @OptIn(ExperimentalLayoutApi::class) @Composable -fun applicationScaffold() { +fun ApplicationScaffold(startRoute: Navigation) { val controller = rememberNavController() @@ -35,11 +39,9 @@ fun applicationScaffold() { contentWindowInsets = WindowInsets.statusBars, floatingActionButtonPosition = FabPosition.Center, topBar = { - topBarImpl(controller) }, floatingActionButton = { - floatingActionButtonImpl(controller) }, content = { paddingValues -> @@ -49,9 +51,33 @@ fun applicationScaffold() { top = paddingValues.calculateTopPadding() ), navController = controller, - startDestination = Navigation.HomeScreen, + startDestination = startRoute, builder = { + this.navigation( + startDestination = Navigation.Unauthenticated.WelcomeScreen + ) { + this.composable { + Column(modifier = Modifier.fillMaxSize().background(SiaTheme.color.text.primary)) {} + } + } + + this.navigation( + startDestination = Navigation.Authenticated.HomeScreen + ) { + this.composable {} + } + + this.composable {} + + + + + + + + + this.homeImpl(controller) this.navigation(startDestination = Navigation.Saved.SavedWordsScreen) { From b4bbd4ba1f73a16b60d5efee127aa7b4035da7d6 Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 16:03:57 +0200 Subject: [PATCH 05/11] Initialize the `feature:authenticate` module - Create a new Android library module `feature:authenticate` with namespace `eu.project.authenticate`. - Configure `build.gradle.kts` with `compileSdk` 36, `minSdk` 26, Java 11 compatibility, and Compose support. - Add dependencies for Hilt (via KSP), Jetpack Compose, Navigation, and Kotlin Serialization. - Include internal project dependencies for `:ui`, `:common`, and `:auth`. - Register the `:feature:authenticate` module in `settings.gradle.kts`. - Add standard ProGuard and gitignore configurations for the new module. --- feature/authenticate/.gitignore | 1 + feature/authenticate/build.gradle.kts | 76 +++++++++++++++++++ feature/authenticate/consumer-rules.pro | 0 feature/authenticate/proguard-rules.pro | 21 +++++ .../authenticate/src/main/AndroidManifest.xml | 2 + settings.gradle.kts | 1 + 6 files changed, 101 insertions(+) create mode 100644 feature/authenticate/.gitignore create mode 100644 feature/authenticate/build.gradle.kts create mode 100644 feature/authenticate/consumer-rules.pro create mode 100644 feature/authenticate/proguard-rules.pro create mode 100644 feature/authenticate/src/main/AndroidManifest.xml 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..43fbb71 --- /dev/null +++ b/feature/authenticate/build.gradle.kts @@ -0,0 +1,76 @@ +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) + + 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/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/settings.gradle.kts b/settings.gradle.kts index 63aeef5..10ea37c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,3 +31,4 @@ include(":feature:transcribe") include(":localData") include(":remoteData") include(":auth") +include(":feature:authenticate") From 8958c049c31a578d3b865b407eb8c2358ee8e604 Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 17:50:19 +0200 Subject: [PATCH 06/11] Implement Google Sign-In with Credential Manager in `AuthnManager` - Create `SignInWithGoogleResult` sealed interface to represent Success, Canceled, and Unknown failure states. - Update `AuthnManager` interface and `AuthnManagerImpl` to handle the Google Sign-In flow internally using `GoogleCredentialManager` and `NonceGenerator`. - Refactor `signInWithGoogle` to no longer require external parameters, returning a `SignInWithGoogleResult` instead. - Update `AuthModule` to inject `GoogleCredentialManager` into `AuthnManagerImpl`. --- .../eu/project/auth/authn/AuthnManager.kt | 8 +--- .../eu/project/auth/authn/AuthnManagerImpl.kt | 37 +++++++++++++------ .../java/eu/project/auth/module/AuthModule.kt | 6 ++- .../auth/result/SignInWithGoogleResult.kt | 9 +++++ 4 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 auth/src/main/java/eu/project/auth/result/SignInWithGoogleResult.kt diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt index 07a0d99..d29fb15 100644 --- a/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManager.kt @@ -1,7 +1,6 @@ package eu.project.auth.authn -import eu.project.auth.nonce.RawNonce -import eu.project.auth.token.GoogleIdToken +import eu.project.auth.result.SignInWithGoogleResult import eu.project.auth.user.User /** @@ -10,10 +9,7 @@ import eu.project.auth.user.User */ interface AuthnManager { - suspend fun signInWithGoogle( - googleIdToken: GoogleIdToken, - rawNonce: RawNonce - ) + suspend fun signInWithGoogle(): SignInWithGoogleResult fun getUser(): User? diff --git a/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt index d15ac09..b4f6629 100644 --- a/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt +++ b/auth/src/main/java/eu/project/auth/authn/AuthnManagerImpl.kt @@ -1,11 +1,13 @@ package eu.project.auth.authn -import eu.project.auth.nonce.RawNonce -import eu.project.auth.token.GoogleIdToken +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 @@ -14,20 +16,31 @@ import javax.inject.Inject /** * Implementation of `AuthnManager` that uses `SupabaseClient` to perform auth-related tasks. */ -internal class AuthnManagerImpl @Inject constructor(supabaseClient: SupabaseClient): AuthnManager { +internal class AuthnManagerImpl @Inject constructor( + supabaseClient: SupabaseClient, + private val googleCredentialManager: GoogleCredentialManager +): AuthnManager { private val client = supabaseClient.client - override suspend fun signInWithGoogle( - googleIdToken: GoogleIdToken, - rawNonce: RawNonce - ) { + override suspend fun signInWithGoogle(): SignInWithGoogleResult { + return try { + val nonceSet = NonceGenerator.generateNonce() + + val googleIdToken = googleCredentialManager.getGoogleIdToken(nonceSet.hashedNonce) - // sign in using Google - client.auth.signInWith(IDToken) { - this.idToken = googleIdToken.value - this.provider = Google - this.nonce = rawNonce.value + 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) + } } } diff --git a/auth/src/main/java/eu/project/auth/module/AuthModule.kt b/auth/src/main/java/eu/project/auth/module/AuthModule.kt index 59823c6..a8e19a9 100644 --- a/auth/src/main/java/eu/project/auth/module/AuthModule.kt +++ b/auth/src/main/java/eu/project/auth/module/AuthModule.kt @@ -21,8 +21,12 @@ internal class AuthModule { @Singleton fun provideAuthManager( supabaseClient: SupabaseClient, + googleCredentialManager: GoogleCredentialManager ): AuthnManager = - AuthnManagerImpl(supabaseClient) + AuthnManagerImpl( + supabaseClient = supabaseClient, + googleCredentialManager = googleCredentialManager + ) @Provides @Singleton 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 From 88d5c733acae94a9d12b17e1dc4e65983412ef7d Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 18:22:02 +0200 Subject: [PATCH 07/11] Implement design system content layout components - Add `ContentHolder` component to provide a consistent background and fullscreen container for screen content. - Add `SplitContent` component to divide the screen into two equal vertically weighted sections with customizable arrangements. - Add `LoadingContent` component utilizing `SplitContent` to display a loading indicator and supporting text. - Integrate testing tags and theme styling across all new components. --- .../design_system/content/ContentHolder.kt | 24 +++++++++ .../design_system/content/LoadingContent.kt | 36 +++++++++++++ .../design_system/content/SplitContent.kt | 54 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 ui/src/main/java/eu/project/design_system/content/ContentHolder.kt create mode 100644 ui/src/main/java/eu/project/design_system/content/LoadingContent.kt create mode 100644 ui/src/main/java/eu/project/design_system/content/SplitContent.kt 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 From 67aec3b6f277f25f54f6ac0b362a76abc70de6a4 Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 18:52:28 +0200 Subject: [PATCH 08/11] Implement `WelcomeScreenViewModel` and unit tests - Implement `WelcomeScreenViewModel` using Hilt, managing state via `MutableStateFlow` and handling the `ClickContinueWithGoogle` intent. - Integrate `AuthnManager` for Google Sign-In and `CrashlyticsManager` for error reporting within the ViewModel. - Add comprehensive unit tests for `WelcomeScreenViewModel` using MockK, Turbine, and `kotlinx-coroutines-test`. - Update `feature/authenticate/build.gradle.kts` with testing dependencies for MockK, Turbine, and Coroutines. --- feature/authenticate/build.gradle.kts | 13 +- .../intent/WelcomeScreenIntent.kt | 5 + .../welcomeScreen/state/WelcomeScreenState.kt | 7 + .../vm/WelcomeScreenViewModel.kt | 58 ++++++++ .../vm/WelcomeScreenViewModelTest.kt | 131 ++++++++++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/intent/WelcomeScreenIntent.kt create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/state/WelcomeScreenState.kt create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModel.kt create mode 100644 feature/authenticate/src/test/java/eu/project/authenticate/welcomeScreen/vm/WelcomeScreenViewModelTest.kt diff --git a/feature/authenticate/build.gradle.kts b/feature/authenticate/build.gradle.kts index 43fbb71..8e77db3 100644 --- a/feature/authenticate/build.gradle.kts +++ b/feature/authenticate/build.gradle.kts @@ -67,9 +67,20 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) - // hiltViewModel + // 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) + implementation(project(":ui")) implementation(project(":common")) implementation(project(":auth")) 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/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..1ea059e --- /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.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +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 = UnconfinedTestDispatcher() +) : 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 From 095312822f3d8da994bb16830c6c0a24d125d5fd Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 19:22:20 +0200 Subject: [PATCH 09/11] Implement `WelcomeScreenViewModel` and unit tests - Implement `WelcomeScreenViewModel` using Hilt, managing state via `MutableStateFlow` and handling the `ClickContinueWithGoogle` intent. - Integrate `AuthnManager` for Google Sign-In and `CrashlyticsManager` for error reporting within the ViewModel. - Add comprehensive unit tests for `WelcomeScreenViewModel` using MockK, Turbine, and `kotlinx-coroutines-test`. - Update `feature/authenticate/build.gradle.kts` with testing dependencies for MockK, Turbine, and Coroutines. --- feature/authenticate/build.gradle.kts | 4 + .../welcomeScreen/screen/WelcomeScreenTest.kt | 124 ++++++++++++++++++ .../welcomeScreen/impl/WelcomeScreenImpl.kt | 36 +++++ .../welcomeScreen/screen/WelcomeScreen.kt | 33 +++++ .../content/WelcomeScreenIdleContent.kt | 59 +++++++++ .../java/eu/project/design_system/TestTag.kt | 9 ++ ui/src/main/res/values/strings.xml | 6 + 7 files changed, 271 insertions(+) create mode 100644 feature/authenticate/src/androidTest/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreenTest.kt create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/impl/WelcomeScreenImpl.kt create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/WelcomeScreen.kt create mode 100644 feature/authenticate/src/main/java/eu/project/authenticate/welcomeScreen/screen/content/WelcomeScreenIdleContent.kt diff --git a/feature/authenticate/build.gradle.kts b/feature/authenticate/build.gradle.kts index 8e77db3..fb95191 100644 --- a/feature/authenticate/build.gradle.kts +++ b/feature/authenticate/build.gradle.kts @@ -81,6 +81,10 @@ dependencies { // 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")) 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/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..3676361 --- /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.welcomeScreenImpl(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/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/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/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. From 22222c0306950221f42bfeeb3baad79778b85b1a Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 19:29:20 +0200 Subject: [PATCH 10/11] Refactor navigation and integrate the authentication module - Move `HomeScreen` from the root `Navigation` object to `Navigation.Authenticated`. - Update the `scaffold` module to include the `:feature:authenticate` dependency. - Replace placeholder navigation blocks in `ApplicationScaffold.kt` with `welcomeImpl` and `homeImpl` extensions. - Rename `welcomeScreenImpl` to `welcomeImpl` and update its corresponding file name. - Update `popUpTo` references in `SavedWordsImpl.kt` and `ExportWordsImpl.kt` to point to the new `Navigation.Authenticated.HomeScreen` route. - Remove unused imports and redundant composable declarations in `ApplicationScaffold.kt`. --- .../java/eu/project/common/navigation/Navigation.kt | 2 -- .../welcomeScreen/impl/WelcomeScreenImpl.kt | 2 +- .../src/main/java/eu/project/home/impl/HomeImpl.kt | 2 +- .../saved/exportWords/impl/ExportWordsImpl.kt | 2 +- .../project/saved/savedWords/impl/SavedWordsImpl.kt | 2 +- scaffold/build.gradle.kts | 1 + .../java/eu/project/scaffold/ApplicationScaffold.kt | 12 +++--------- 7 files changed, 8 insertions(+), 15 deletions(-) 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 e8844c9..372b68f 100644 --- a/common/src/main/java/eu/project/common/navigation/Navigation.kt +++ b/common/src/main/java/eu/project/common/navigation/Navigation.kt @@ -24,8 +24,6 @@ sealed class Navigation { data object InitializationErrorScreen: Navigation() - @Serializable - data object HomeScreen: Navigation() sealed class Saved: Navigation() { 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 index 3676361..06b5f97 100644 --- 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 @@ -12,7 +12,7 @@ import eu.project.authenticate.welcomeScreen.state.WelcomeScreenState import eu.project.authenticate.welcomeScreen.vm.WelcomeScreenViewModel import eu.project.common.navigation.Navigation -fun NavGraphBuilder.welcomeScreenImpl(controller: NavHostController) { +fun NavGraphBuilder.welcomeImpl(controller: NavHostController) { composable { val viewModel = hiltViewModel() 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/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 1176fd1..54d054e 100644 --- a/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt +++ b/scaffold/src/main/java/eu/project/scaffold/ApplicationScaffold.kt @@ -1,7 +1,5 @@ package eu.project.scaffold -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -15,8 +13,8 @@ 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.design_system.theme.SiaTheme import eu.project.floatingActionButton.impl.floatingActionButtonImpl import eu.project.home.impl.homeImpl import eu.project.saved.exportResult.impl.exportResultImpl @@ -57,15 +55,13 @@ fun ApplicationScaffold(startRoute: Navigation) { this.navigation( startDestination = Navigation.Unauthenticated.WelcomeScreen ) { - this.composable { - Column(modifier = Modifier.fillMaxSize().background(SiaTheme.color.text.primary)) {} - } + this.welcomeImpl(controller) } this.navigation( startDestination = Navigation.Authenticated.HomeScreen ) { - this.composable {} + this.homeImpl(controller) } this.composable {} @@ -78,8 +74,6 @@ fun ApplicationScaffold(startRoute: Navigation) { - this.homeImpl(controller) - this.navigation(startDestination = Navigation.Saved.SavedWordsScreen) { this.savedWordsImpl(controller) From 457f8dc5a7d70c6f8cf4fdec9dd97bb56e9bd56d Mon Sep 17 00:00:00 2001 From: Marcello Date: Sat, 4 Apr 2026 20:51:24 +0200 Subject: [PATCH 11/11] Update `MainDispatcherRule` to use `StandardTestDispatcher` - Replace `UnconfinedTestDispatcher` with `StandardTestDispatcher` as the default dispatcher in `MainDispatcherRule`. - Update imports in `WelcomeScreenViewModelTest.kt` to reflect the change. --- .../welcomeScreen/vm/WelcomeScreenViewModelTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 1ea059e..948cc87 100644 --- 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 @@ -13,8 +13,8 @@ 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.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -26,7 +26,7 @@ import org.junit.runner.Description @ExperimentalCoroutinesApi class MainDispatcherRule( - val dispatcher: TestDispatcher = UnconfinedTestDispatcher() + val dispatcher: TestDispatcher = StandardTestDispatcher() ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(dispatcher)