Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
12 changes: 12 additions & 0 deletions app/src/main/java/eu/project/sia/ApplicationStartupState.kt
Original file line number Diff line number Diff line change
@@ -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()
}
56 changes: 56 additions & 0 deletions app/src/main/java/eu/project/sia/ApplicationViewModel.kt
Original file line number Diff line number Diff line change
@@ -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>(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
)
}
}
}
}
}
22 changes: 19 additions & 3 deletions app/src/main/java/eu/project/sia/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ 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
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

Expand All @@ -25,6 +28,8 @@ class MainActivity : ComponentActivity() {
@SaveFileEventBusQualifier
lateinit var saveFileEventBus: EventBus<SaveFileEvent>

private val applicationViewModel: ApplicationViewModel by viewModels()

private var csvFile: CsvFile? = null

private val saveFileLauncher = registerForActivityResult(contract = SaveFileContract()) { uri: Uri? ->
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 123 additions & 0 deletions app/src/test/java/eu/project/sia/ApplicationViewModelTest.kt
Original file line number Diff line number Diff line change
@@ -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<AuthnManager>(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()
}
}
}
1 change: 1 addition & 0 deletions auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
93 changes: 93 additions & 0 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
}
Empty file added auth/consumer-rules.pro
Empty file.
Loading
Loading