From d2701efa3bdf8b60b3e871a45e5bd0ac1cf37f7a Mon Sep 17 00:00:00 2001 From: Kilian Schneider <48420258+Basler182@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:50:39 +0200 Subject: [PATCH] added auth state listener to directly navigate to home when logged in (#50) # *auth state listener* ## :recycle: Current situation & Problem #37 ## :gear: Release Notes - added auth state listener to directly navigate to home when logged in ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --------- Signed-off-by: Basler182 Co-authored-by: eldcn --- app/build.gradle.kts | 7 +- .../bdh/engagehf/MainActivityViewModel.kt | 22 ++ .../education/EngageEducationRepository.kt | 2 +- ...ideoSectionDocumentToVideoSectionMapper.kt | 2 +- .../engagehf/navigation/screens/AppScreen.kt | 6 +- .../onboarding/EngageConsentManager.kt | 20 +- .../bdh/engagehf/onboarding/FirebaseModule.kt | 52 ----- .../bdh/engagehf/MainActivityViewModelTest.kt | 143 +++++++++++- .../onboarding/EngageConsentManagerTest.kt | 70 ++++++ .../bluetooth/domain/BLEDeviceConnector.kt | 3 +- .../domain/BLEDeviceConnectorTest.kt | 1 + modules/account/build.gradle.kts | 9 +- .../spezi/module/account/di/AccountModule.kt | 60 +++++ .../module/account/login/LoginViewModel.kt | 2 +- .../AuthenticationManager.kt} | 31 ++- .../manager/CredentialLoginManagerAuth.kt | 14 +- .../manager/CredentialRegisterManagerAuth.kt | 12 +- .../manager}/FirebaseInvitationAuthManager.kt | 14 +- .../account/manager}/InvitationAuthManager.kt | 2 +- .../account/manager/UserSessionManager.kt | 68 ++++++ .../spezi/module/account/manager/UserState.kt | 23 ++ .../account/register/RegisterViewModel.kt | 2 +- .../account/login/LoginViewModelTest.kt | 2 +- .../CredentialRegisterManagerAuthTest.kt | 12 +- .../account/manager/UserSessionManagerTest.kt | 206 ++++++++++++++++++ .../account/register/RegisterViewModelTest.kt | 2 +- .../education/videos/EducationScreen.kt | 1 + modules/onboarding/build.gradle.kts | 6 +- .../onboarding/di/TestOnboardingModule.kt | 10 +- .../onboarding/consent/ConsentManager.kt | 3 +- .../onboarding/consent/ConsentViewModel.kt | 59 ++--- .../onboarding/consent/FirebasePdfService.kt | 30 --- .../onboarding/consent/PdfCreationService.kt | 2 +- .../module/onboarding/consent/PdfService.kt | 6 - .../module/onboarding/di/OnboardingModule.kt | 28 --- .../invitation/InvitationCodeViewModel.kt | 1 + .../consent/ConsentViewModelTest.kt | 38 +++- .../invitation/InvitationCodeViewModelTest.kt | 1 + 38 files changed, 727 insertions(+), 245 deletions(-) delete mode 100644 app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/FirebaseModule.kt create mode 100644 app/src/test/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManagerTest.kt rename modules/account/src/main/kotlin/edu/stanford/spezi/module/account/{cred/manager/FirebaseAuthManager.kt => manager/AuthenticationManager.kt} (91%) rename modules/account/src/main/kotlin/edu/stanford/spezi/module/account/{cred => }/manager/CredentialLoginManagerAuth.kt (85%) rename modules/account/src/main/kotlin/edu/stanford/spezi/module/account/{cred => }/manager/CredentialRegisterManagerAuth.kt (89%) rename modules/{onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation => account/src/main/kotlin/edu/stanford/spezi/module/account/manager}/FirebaseInvitationAuthManager.kt (73%) rename modules/{onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation => account/src/main/kotlin/edu/stanford/spezi/module/account/manager}/InvitationAuthManager.kt (66%) create mode 100644 modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt create mode 100644 modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserState.kt rename modules/account/src/test/java/edu/stanford/spezi/module/account/{cred => }/manager/CredentialRegisterManagerAuthTest.kt (87%) create mode 100644 modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt delete mode 100644 modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/FirebasePdfService.kt delete mode 100644 modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfService.kt delete mode 100644 modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/di/OnboardingModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 799b0f2a5..6596cff02 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,8 @@ dependencies { implementation(project(":modules:education")) implementation(project(":modules:onboarding")) + implementation(libs.firebase.firestore.ktx) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.view.model.ktx) @@ -53,10 +55,5 @@ dependencies { implementation(libs.navigation.compose) implementation(libs.kotlinx.serialization.json) - implementation(libs.firebase.functions.ktx) - implementation(libs.firebase.auth.ktx) - implementation(libs.firebase.firestore.ktx) - implementation(libs.firebase.storage.ktx) - androidTestImplementation(project(":core:testing")) } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModel.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModel.kt index b73817e7a..37d598e3d 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModel.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModel.kt @@ -11,9 +11,13 @@ import edu.stanford.spezi.core.logging.speziLogger import edu.stanford.spezi.core.navigation.NavigationEvent import edu.stanford.spezi.core.navigation.Navigator import edu.stanford.spezi.module.account.AccountEvents +import edu.stanford.spezi.module.account.manager.UserSessionManager +import edu.stanford.spezi.module.account.manager.UserState +import edu.stanford.spezi.module.onboarding.OnboardingNavigationEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,6 +27,7 @@ import edu.stanford.spezi.core.design.R as DesignR class MainActivityViewModel @Inject constructor( private val accountEvents: AccountEvents, private val navigator: Navigator, + private val userSessionManager: UserSessionManager, ) : ViewModel() { private val logger by speziLogger() @@ -36,6 +41,10 @@ class MainActivityViewModel @Inject constructor( val uiState = _uiState.asStateFlow() init { + startObserving() + } + + private fun startObserving() { viewModelScope.launch { accountEvents.events.collect { event -> when (event) { @@ -49,6 +58,19 @@ class MainActivityViewModel @Inject constructor( } } } + + viewModelScope.launch { + userSessionManager.userState + .filterIsInstance() + .collect { userState -> + val navigationEvent = if (userState.hasConsented) { + AppNavigationEvent.AppScreen + } else { + OnboardingNavigationEvent.ConsentScreen + } + navigator.navigateTo(event = navigationEvent) + } + } } fun onAction(action: Action) { diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/EngageEducationRepository.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/EngageEducationRepository.kt index d7122bbba..17a5bc768 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/EngageEducationRepository.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/EngageEducationRepository.kt @@ -1,9 +1,9 @@ package edu.stanford.bdh.engagehf.education import com.google.firebase.firestore.FirebaseFirestore -import edu.stanford.spezi.module.onboarding.invitation.await import edu.stanford.spezi.modules.education.videos.VideoSection import edu.stanford.spezi.modules.education.videos.data.repository.EducationRepository +import kotlinx.coroutines.tasks.await import javax.inject.Inject class EngageEducationRepository @Inject constructor( diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapper.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapper.kt index 814e5b327..94739564b 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapper.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/education/VideoSectionDocumentToVideoSectionMapper.kt @@ -1,9 +1,9 @@ package edu.stanford.bdh.engagehf.education import com.google.firebase.firestore.DocumentSnapshot -import edu.stanford.spezi.module.onboarding.invitation.await import edu.stanford.spezi.modules.education.videos.Video import edu.stanford.spezi.modules.education.videos.VideoSection +import kotlinx.coroutines.tasks.await import java.util.Locale import javax.inject.Inject diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt index 815b5fb8c..62ce33106 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/navigation/screens/AppScreen.kt @@ -1,5 +1,6 @@ package edu.stanford.bdh.engagehf.navigation.screens +import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -27,7 +29,9 @@ import edu.stanford.spezi.modules.education.videos.EducationScreen @Composable fun AppScreen() { - val viewModel = hiltViewModel() + val viewModel = hiltViewModel( + viewModelStoreOwner = LocalContext.current as ComponentActivity + ) val uiState by viewModel.uiState.collectAsState() AppScreen( uiState = uiState, diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManager.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManager.kt index 93b3cd583..7cb237bd7 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManager.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManager.kt @@ -2,16 +2,13 @@ package edu.stanford.bdh.engagehf.onboarding import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent import edu.stanford.spezi.core.navigation.Navigator +import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.onboarding.consent.ConsentManager -import edu.stanford.spezi.module.onboarding.consent.ConsentUiState -import edu.stanford.spezi.module.onboarding.consent.PdfCreationService -import edu.stanford.spezi.module.onboarding.consent.PdfService import javax.inject.Inject class EngageConsentManager @Inject internal constructor( - private val pdfService: PdfService, private val navigator: Navigator, - private val pdfCreationService: PdfCreationService, + private val messageNotifier: MessageNotifier, ) : ConsentManager { override suspend fun getMarkdownText(): String { @@ -23,12 +20,11 @@ class EngageConsentManager @Inject internal constructor( """.trimIndent() } - override suspend fun onConsented(uiState: ConsentUiState): Result = runCatching { - val pdfBytes = pdfCreationService.createPdf(uiState) - if (pdfService.uploadPdf(pdfBytes).getOrThrow()) { - navigator.navigateTo(AppNavigationEvent.AppScreen) - } else { - error("Upload went wrong") - } + override suspend fun onConsented() { + navigator.navigateTo(AppNavigationEvent.AppScreen) + } + + override suspend fun onConsentFailure(error: Throwable) { + messageNotifier.notify(message = "Something went wrong, failed to submit the consent!") } } diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/FirebaseModule.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/FirebaseModule.kt deleted file mode 100644 index 4f0f489f2..000000000 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/onboarding/FirebaseModule.kt +++ /dev/null @@ -1,52 +0,0 @@ -package edu.stanford.bdh.engagehf.onboarding - -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.firestore.FirebaseFirestore -import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.storage.FirebaseStorage -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import edu.stanford.bdh.engagehf.BuildConfig - -@Module -@InstallIn(SingletonComponent::class) -class FirebaseModule { - - companion object { - private const val FIREBASE_EMULATOR_HOST = "10.0.2.2" - private const val FIREBASE_FUNCTIONS_EMULATOR_PORT = 5001 - private const val FIREBASE_AUTH_EMULATOR_PORT = 9099 - private const val FIREBASE_FIRESTORE_EMULATOR_PORT = 8080 - private const val FIREBASE_STORAGE_EMULATOR_PORT = 9199 - } - - @Provides - fun provideFirebaseFunctions(): FirebaseFunctions = FirebaseFunctions.getInstance().apply { - if (BuildConfig.USE_FIREBASE_EMULATOR) { - useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_FUNCTIONS_EMULATOR_PORT) - } - } - - @Provides - fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance().apply { - if (BuildConfig.USE_FIREBASE_EMULATOR) { - useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_AUTH_EMULATOR_PORT) - } - } - - @Provides - fun provideFirebaseFirestore(): FirebaseFirestore = FirebaseFirestore.getInstance().apply { - if (BuildConfig.USE_FIREBASE_EMULATOR) { - useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_FIRESTORE_EMULATOR_PORT) - } - } - - @Provides - fun provideFirebaseStorage(): FirebaseStorage = FirebaseStorage.getInstance().apply { - if (BuildConfig.USE_FIREBASE_EMULATOR) { - useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_STORAGE_EMULATOR_PORT) - } - } -} diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModelTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModelTest.kt index 45c00b623..104d713a8 100644 --- a/app/src/test/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModelTest.kt +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/MainActivityViewModelTest.kt @@ -1,32 +1,157 @@ package edu.stanford.bdh.engagehf import com.google.common.truth.Truth.assertThat +import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.spezi.core.navigation.NavigationEvent import edu.stanford.spezi.core.navigation.Navigator import edu.stanford.spezi.core.testing.CoroutineTestRule import edu.stanford.spezi.core.testing.runTestUnconfined import edu.stanford.spezi.module.account.AccountEvents +import edu.stanford.spezi.module.account.manager.UserSessionManager +import edu.stanford.spezi.module.account.manager.UserState +import edu.stanford.spezi.module.onboarding.OnboardingNavigationEvent +import io.mockk.Called +import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Rule import org.junit.Test -@ExperimentalCoroutinesApi class MainActivityViewModelTest { - private var mockAccountEvents: AccountEvents = mockk(relaxed = true) - private var mockNavigator: Navigator = mockk(relaxed = true) - - private lateinit var viewModel: MainActivityViewModel - @get:Rule val coroutineTestRule = CoroutineTestRule() + private val accountEventsFlow = MutableSharedFlow() + private val accountEvents: AccountEvents = mockk(relaxed = true) + private val navigator: Navigator = mockk(relaxed = true) + private val userStateFlow = MutableStateFlow(UserState.NotInitialized) + private val userSessionManager: UserSessionManager = mockk() + private lateinit var viewModel: MainActivityViewModel + @Before - fun setup() { - viewModel = MainActivityViewModel(mockAccountEvents, mockNavigator) + fun setUp() { + every { accountEvents.events } returns accountEventsFlow + every { userSessionManager.userState } returns userStateFlow + viewModel = MainActivityViewModel( + accountEvents = accountEvents, + navigator = navigator, + userSessionManager = userSessionManager, + ) + } + + @Test + fun `it should start observing on init`() { + verify { accountEvents.events } + verify { userSessionManager.userState } + } + + @Test + fun `it should navigate to app screen on SignUpSuccess event`() = runTestUnconfined { + // given + val event = AccountEvents.Event.SignUpSuccess + + // when + accountEventsFlow.emit(event) + + // then + verify { navigator.navigateTo(event = AppNavigationEvent.AppScreen) } + } + + @Test + fun `it should navigate to app screen on SignInSuccess event`() = runTestUnconfined { + // given + val event = AccountEvents.Event.SignInSuccess + + // when + accountEventsFlow.emit(event) + + // then + verify { navigator.navigateTo(event = AppNavigationEvent.AppScreen) } + } + + @Test + fun `it should not navigate on other account events`() = runTestUnconfined { + // given + val event = AccountEvents.Event.SignInFailure + + // when + accountEventsFlow.emit(event) + + // then + verify { navigator wasNot Called } + } + + @Test + fun `it should return navigation events`() { + // given + val events: SharedFlow = mockk() + every { navigator.events } returns events + + // when + val result = viewModel.getNavigationEvents() + + // then + assertThat(result).isEqualTo(events) } + @Test + fun `it should navigate to app screen for registered user if consented`() = + runTestUnconfined { + // given + val userState = UserState.Registered(hasConsented = true) + + // when + userStateFlow.update { userState } + + // then + verify { navigator.navigateTo(event = AppNavigationEvent.AppScreen) } + } + + @Test + fun `it should navigate to consent screen for registered user if not consented`() = + runTestUnconfined { + // given + val userState = UserState.Registered(hasConsented = false) + + // when + userStateFlow.update { userState } + + // then + verify { navigator.navigateTo(event = OnboardingNavigationEvent.ConsentScreen) } + } + + @Test + fun `it should not navigate for not initialized users`() = + runTestUnconfined { + // given + val userState = UserState.NotInitialized + + // when + userStateFlow.update { userState } + + // then + verify { navigator wasNot Called } + } + + @Test + fun `it should not navigate for anonymous users`() = + runTestUnconfined { + // given + val userState = UserState.Anonymous + + // when + userStateFlow.update { userState } + + // then + verify { navigator wasNot Called } + } + @Test fun `given selectedItem when onAction UpdateSelectedItem then uiState selectedItem should be updated`() = runTestUnconfined { diff --git a/app/src/test/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManagerTest.kt b/app/src/test/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManagerTest.kt new file mode 100644 index 000000000..da0c86252 --- /dev/null +++ b/app/src/test/kotlin/edu/stanford/bdh/engagehf/onboarding/EngageConsentManagerTest.kt @@ -0,0 +1,70 @@ +package edu.stanford.bdh.engagehf.onboarding + +import com.google.common.truth.Truth.assertThat +import edu.stanford.bdh.engagehf.navigation.AppNavigationEvent +import edu.stanford.spezi.core.navigation.Navigator +import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.core.utils.MessageNotifier +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class EngageConsentManagerTest { + private val navigator: Navigator = mockk() + private val messageNotifier: MessageNotifier = mockk() + private val manager = EngageConsentManager( + navigator = navigator, + messageNotifier = messageNotifier, + ) + + @Before + fun setup() { + every { navigator.navigateTo(AppNavigationEvent.AppScreen) } just Runs + every { messageNotifier.notify(any(), any()) } just Runs + } + + @Test + fun `it should return the correct markdown test`() = runTestUnconfined { + // given + val expectedText = """ + # Consent + The ENGAGE-HF Android Mobile Application will connect to external devices via Bluetooth to record personal health information, including weight, heart rate, and blood pressure. + + Your personal information will only be shared with the research team conducting the study. + """.trimIndent() + + // when + val result = manager.getMarkdownText() + + // then + assertThat(result).isEqualTo(expectedText) + } + + @Test + fun `it should navigate to bluetooth screen on consented`() = runTestUnconfined { + // given + val navigationEvent = AppNavigationEvent.AppScreen + + // when + manager.onConsented() + + // then + verify { navigator.navigateTo(event = navigationEvent) } + } + + @Test + fun `it should notify error message on on consent failure`() = runTestUnconfined { + // given + val message = "Something went wrong, failed to submit the consent!" + + // when + manager.onConsentFailure(error = mockk()) + + // then + verify { messageNotifier.notify(message = message) } + } +} diff --git a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnector.kt b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnector.kt index a0d5486e8..4fc0aab16 100644 --- a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnector.kt +++ b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnector.kt @@ -1,6 +1,5 @@ package edu.stanford.spezi.core.bluetooth.domain -import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback @@ -34,7 +33,7 @@ import java.util.concurrent.atomic.AtomicBoolean * @property scope The coroutine scope used for launching connection events and mapping measurements. * @property context The application context. */ -@SuppressLint("MissingPermission") +@Suppress("DEPRECATION", "MissingPermission") internal class BLEDeviceConnector @AssistedInject constructor( @Assisted private val device: BluetoothDevice, private val measurementMapper: MeasurementMapper, diff --git a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnectorTest.kt b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnectorTest.kt index e85879b7b..f4365e4b5 100644 --- a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnectorTest.kt +++ b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEDeviceConnectorTest.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.first import org.junit.Before import org.junit.Test +@Suppress("DEPRECATION") class BLEDeviceConnectorTest { private val device: BluetoothDevice = mockk() private val measurementMapper: MeasurementMapper = mockk() diff --git a/modules/account/build.gradle.kts b/modules/account/build.gradle.kts index 20a8f5ffd..d661e616e 100644 --- a/modules/account/build.gradle.kts +++ b/modules/account/build.gradle.kts @@ -7,6 +7,10 @@ plugins { android { namespace = "edu.stanford.spezi.module.account" + buildFeatures { + buildConfig = true + } + packaging { resources { excludes += "/META-INF/**.md" @@ -22,12 +26,13 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(libs.firebase.functions.ktx) + implementation(libs.firebase.auth.ktx) + implementation(libs.firebase.firestore.ktx) + implementation(libs.firebase.storage.ktx) implementation(libs.androidx.credentials.play.services.auth) - implementation(libs.firebase.auth.ktx) implementation(libs.play.services.auth) implementation(libs.googleid) - implementation(libs.firebase.firestore.ktx) testImplementation(libs.bundles.unit.testing) androidTestImplementation(libs.bundles.compose.androidTest) diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/di/AccountModule.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/di/AccountModule.kt index ce1804d9f..d8f8ff29c 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/di/AccountModule.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/di/AccountModule.kt @@ -4,16 +4,25 @@ import android.content.Context import androidx.credentials.CredentialManager import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.auth.api.identity.SignInClient +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.storage.FirebaseStorage +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import edu.stanford.spezi.module.account.BuildConfig +import edu.stanford.spezi.module.account.manager.FirebaseInvitationAuthManager +import edu.stanford.spezi.module.account.manager.InvitationAuthManager import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) class AccountModule { + private val userFirebaseEmulator by lazy { BuildConfig.DEBUG } @Singleton @Provides @@ -26,4 +35,55 @@ class AccountModule { fun provideCredentialManager(@ApplicationContext context: Context): CredentialManager { return CredentialManager.create(context) } + + @Provides + @Singleton + internal fun provideFirebaseFunctions(): FirebaseFunctions = + FirebaseFunctions.getInstance().apply { + if (userFirebaseEmulator) { + useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_FUNCTIONS_EMULATOR_PORT) + } + } + + @Provides + @Singleton + internal fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance().apply { + if (userFirebaseEmulator) { + useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_AUTH_EMULATOR_PORT) + } + } + + @Provides + @Singleton + internal fun provideFirebaseFirestore(): FirebaseFirestore = + FirebaseFirestore.getInstance().apply { + if (userFirebaseEmulator) { + useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_FIRESTORE_EMULATOR_PORT) + } + } + + @Provides + @Singleton + internal fun provideFirebaseStorage(): FirebaseStorage = FirebaseStorage.getInstance().apply { + if (userFirebaseEmulator) { + useEmulator(FIREBASE_EMULATOR_HOST, FIREBASE_STORAGE_EMULATOR_PORT) + } + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + @Binds + internal abstract fun bindInvitationAuthManager( + firebaseInvitationAuthManager: FirebaseInvitationAuthManager, + ): InvitationAuthManager + } + + private companion object { + const val FIREBASE_EMULATOR_HOST = "10.0.2.2" + const val FIREBASE_FUNCTIONS_EMULATOR_PORT = 5001 + const val FIREBASE_AUTH_EMULATOR_PORT = 9099 + const val FIREBASE_FIRESTORE_EMULATOR_PORT = 8080 + const val FIREBASE_STORAGE_EMULATOR_PORT = 9199 + } } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt index b524b0f25..ee5cea0ea 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/login/LoginViewModel.kt @@ -7,7 +7,7 @@ import edu.stanford.spezi.core.navigation.Navigator import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.account.AccountEvents import edu.stanford.spezi.module.account.AccountNavigationEvent -import edu.stanford.spezi.module.account.cred.manager.CredentialLoginManagerAuth +import edu.stanford.spezi.module.account.manager.CredentialLoginManagerAuth import edu.stanford.spezi.module.account.register.FieldState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/FirebaseAuthManager.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/AuthenticationManager.kt similarity index 91% rename from modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/FirebaseAuthManager.kt rename to modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/AuthenticationManager.kt index a2d97f2bc..262388c28 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/FirebaseAuthManager.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/AuthenticationManager.kt @@ -1,6 +1,6 @@ @file:Suppress("LongParameterList") -package edu.stanford.spezi.module.account.cred.manager +package edu.stanford.spezi.module.account.manager import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser @@ -13,19 +13,12 @@ import java.time.LocalDate import javax.inject.Inject import kotlin.coroutines.resumeWithException -class FirebaseAuthManager @Inject constructor( +internal class AuthenticationManager @Inject constructor( private val firebaseAuth: FirebaseAuth, private val firestore: FirebaseFirestore, ) { private val logger by speziLogger() - private suspend fun com.google.android.gms.tasks.Task.await(): T { - return suspendCancellableCoroutine { cont -> - addOnSuccessListener { result -> cont.resume(result) { } } - addOnFailureListener { exception -> cont.resumeWithException(exception) } - } - } - suspend fun linkUserToGoogleAccount(googleIdToken: String): Boolean { return runCatching { val credential = GoogleAuthProvider.getCredential(googleIdToken, null) @@ -88,22 +81,21 @@ class FirebaseAuthManager @Inject constructor( ) firestore.collection("users").document(user.uid).set(userMap).await() } - true - }.getOrElse { e -> + }.onFailure { e -> logger.e { "Error saving user data: ${e.message}" } - false - } + }.isSuccess } - internal suspend fun sendForgotPasswordEmail(email: String): Result { - return kotlin.runCatching { + suspend fun sendForgotPasswordEmail(email: String): Result { + return runCatching { firebaseAuth.sendPasswordResetEmail(email).await() + Unit }.onFailure { e -> logger.e { "Error sending forgot password email: ${e.message}" } } } - internal suspend fun signInWithEmailAndPassword(email: String, password: String): Boolean { + suspend fun signInWithEmailAndPassword(email: String, password: String): Boolean { return runCatching { val result = firebaseAuth.signInWithEmailAndPassword(email, password).await() result.user != null @@ -123,4 +115,11 @@ class FirebaseAuthManager @Inject constructor( logger.e { "Error signing in with google: ${it.message}" } } } + + private suspend fun com.google.android.gms.tasks.Task.await(): T { + return suspendCancellableCoroutine { cont -> + addOnSuccessListener { result -> cont.resume(result) { } } + addOnFailureListener { exception -> cont.resumeWithException(exception) } + } + } } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialLoginManagerAuth.kt similarity index 85% rename from modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt rename to modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialLoginManagerAuth.kt index 7855eed52..eb8b99106 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialLoginManagerAuth.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialLoginManagerAuth.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.module.account.cred.manager +package edu.stanford.spezi.module.account.manager import android.content.Context import androidx.credentials.CredentialManager @@ -13,9 +13,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import edu.stanford.spezi.core.logging.speziLogger import javax.inject.Inject -class CredentialLoginManagerAuth @Inject constructor( +internal class CredentialLoginManagerAuth @Inject constructor( private val credentialManager: CredentialManager, - private val firebaseAuthManager: FirebaseAuthManager, + private val authenticationManager: AuthenticationManager, @ApplicationContext private val context: Context, ) { private val logger by speziLogger() @@ -23,7 +23,7 @@ class CredentialLoginManagerAuth @Inject constructor( suspend fun handleGoogleSignIn(): Result { val result = getCredential(true) return if (result != null) { - firebaseAuthManager.signInWithGoogle(result.idToken) + authenticationManager.signInWithGoogle(result.idToken) } else { Result.success(false) } @@ -34,7 +34,7 @@ class CredentialLoginManagerAuth @Inject constructor( password: String, ): Result { return runCatching { - firebaseAuthManager.signInWithEmailAndPassword(username, password) + authenticationManager.signInWithEmailAndPassword(username, password) } } @@ -79,7 +79,7 @@ class CredentialLoginManagerAuth @Inject constructor( } } - suspend fun sendForgotPasswordEmail(email: String): Result { - return firebaseAuthManager.sendForgotPasswordEmail(email) + suspend fun sendForgotPasswordEmail(email: String): Result { + return authenticationManager.sendForgotPasswordEmail(email) } } diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuth.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuth.kt similarity index 89% rename from modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuth.kt rename to modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuth.kt index 5d0f6ed25..b095ab821 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuth.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuth.kt @@ -1,6 +1,6 @@ @file:Suppress("LongParameterList") -package edu.stanford.spezi.module.account.cred.manager +package edu.stanford.spezi.module.account.manager import android.content.Context import androidx.credentials.CredentialManager @@ -13,8 +13,8 @@ import edu.stanford.spezi.core.logging.speziLogger import java.time.LocalDate import javax.inject.Inject -class CredentialRegisterManagerAuth @Inject internal constructor( - private val firebaseAuthManager: FirebaseAuthManager, +internal class CredentialRegisterManagerAuth @Inject internal constructor( + private val authenticationManager: AuthenticationManager, private val credentialManager: CredentialManager, @ApplicationContext private val context: Context, ) { @@ -30,9 +30,9 @@ class CredentialRegisterManagerAuth @Inject internal constructor( dateOfBirth: LocalDate, ): Result { return runCatching { - val linkResult = firebaseAuthManager.linkUserToGoogleAccount(idToken) + val linkResult = authenticationManager.linkUserToGoogleAccount(idToken) val saveResult = if (linkResult) { - firebaseAuthManager.saveUserData( + authenticationManager.saveUserData( firstName = firstName, lastName = lastName, selectedGender = selectedGender, @@ -54,7 +54,7 @@ class CredentialRegisterManagerAuth @Inject internal constructor( selectedGender: String, dateOfBirth: LocalDate, ): Result { - return firebaseAuthManager.signUpWithEmailAndPassword( + return authenticationManager.signUpWithEmailAndPassword( email = email, password = password, firstName = firstName, diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/FirebaseInvitationAuthManager.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/FirebaseInvitationAuthManager.kt similarity index 73% rename from modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/FirebaseInvitationAuthManager.kt rename to modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/FirebaseInvitationAuthManager.kt index 50640b427..0cf8ce440 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/FirebaseInvitationAuthManager.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/FirebaseInvitationAuthManager.kt @@ -1,12 +1,10 @@ -package edu.stanford.spezi.module.onboarding.invitation +package edu.stanford.spezi.module.account.manager import com.google.firebase.auth.FirebaseAuth import com.google.firebase.functions.FirebaseFunctions import edu.stanford.spezi.core.logging.speziLogger -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await import javax.inject.Inject -import kotlin.coroutines.resumeWithException internal class FirebaseInvitationAuthManager @Inject constructor( private val functions: FirebaseFunctions, @@ -44,11 +42,3 @@ internal class FirebaseInvitationAuthManager @Inject constructor( } ?: Result.failure(Exception("Failed to check invitation code")) } } - -@OptIn(ExperimentalCoroutinesApi::class) -suspend fun com.google.android.gms.tasks.Task.await(): T { - return suspendCancellableCoroutine { cont -> - addOnSuccessListener { result -> cont.resume(result) { } } - addOnFailureListener { exception -> cont.resumeWithException(exception) } - } -} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationAuthManager.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/InvitationAuthManager.kt similarity index 66% rename from modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationAuthManager.kt rename to modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/InvitationAuthManager.kt index f64851b63..42fcab4b0 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationAuthManager.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/InvitationAuthManager.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.module.onboarding.invitation +package edu.stanford.spezi.module.account.manager interface InvitationAuthManager { suspend fun checkInvitationCode(invitationCode: String): Result diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt new file mode 100644 index 000000000..a23fa5e0e --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserSessionManager.kt @@ -0,0 +1,68 @@ +package edu.stanford.spezi.module.account.manager + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.storage.FirebaseStorage +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.core.logging.speziLogger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserSessionManager @Inject constructor( + private val firebaseStorage: FirebaseStorage, + private val firebaseAuth: FirebaseAuth, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, + @Dispatching.IO private val coroutineScope: CoroutineScope, +) { + private val logger by speziLogger() + + private val authStateListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + val user = firebaseAuth.currentUser ?: return@AuthStateListener + if (user.isAnonymous) { + _userState.update { UserState.Anonymous } + } else { + coroutineScope.launch { + _userState.update { UserState.Registered(hasConsented = hasConsented()) } + } + } + } + + private val _userState = MutableStateFlow(value = UserState.NotInitialized) + val userState: StateFlow get() = _userState.asStateFlow() + + init { + firebaseAuth.addAuthStateListener(authStateListener) + } + + suspend fun uploadConsentPdf(pdfBytes: ByteArray): Result = withContext(ioDispatcher) { + runCatching { + val currentUser = firebaseAuth.currentUser ?: error("User not available") + val inputStream = ByteArrayInputStream(pdfBytes) + logger.i { "Uploading file to Firebase Storage" } + val uploaded = firebaseStorage + .getReference("users/${currentUser.uid}/signature.pdf") + .putStream(inputStream) + .await().task.isSuccessful + + if (!uploaded) error("Failed to upload signature.pdf") + } + } + + private suspend fun hasConsented(): Boolean = withContext(ioDispatcher) { + runCatching { + val uid = firebaseAuth.uid ?: error("No uid available") + val reference = firebaseStorage.getReference("users/$uid/signature.pdf") + reference.metadata.await() + }.isSuccess + } +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserState.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserState.kt new file mode 100644 index 000000000..eba59e6d6 --- /dev/null +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/manager/UserState.kt @@ -0,0 +1,23 @@ +package edu.stanford.spezi.module.account.manager + +/** + * Encapsulated possible user states + */ +sealed interface UserState { + /** + * User information not received yet. Represents the initial state + */ + data object NotInitialized : UserState + + /** + * Indicates an anonymous user state + */ + data object Anonymous : UserState + + /** + * Indicates a registered user. + * + * @property hasConsented Whether the consent pdf has been submitted or not + */ + data class Registered(val hasConsented: Boolean) : UserState +} diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt index d6b9120d7..ca7163426 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt +++ b/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/RegisterViewModel.kt @@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import edu.stanford.spezi.core.logging.speziLogger import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.account.AccountEvents -import edu.stanford.spezi.module.account.cred.manager.CredentialRegisterManagerAuth +import edu.stanford.spezi.module.account.manager.CredentialRegisterManagerAuth import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt index 4adb8dcea..0ac2ed312 100644 --- a/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/login/LoginViewModelTest.kt @@ -7,7 +7,7 @@ import edu.stanford.spezi.core.testing.runTestUnconfined import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.account.AccountEvents import edu.stanford.spezi.module.account.AccountNavigationEvent -import edu.stanford.spezi.module.account.cred.manager.CredentialLoginManagerAuth +import edu.stanford.spezi.module.account.manager.CredentialLoginManagerAuth import io.mockk.Runs import io.mockk.coVerify import io.mockk.every diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuthTest.kt similarity index 87% rename from modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt rename to modules/account/src/test/java/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuthTest.kt index b2ea4ad99..db60b4428 100644 --- a/modules/account/src/test/java/edu/stanford/spezi/module/account/cred/manager/CredentialRegisterManagerAuthTest.kt +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/CredentialRegisterManagerAuthTest.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.module.account.cred.manager +package edu.stanford.spezi.module.account.manager import android.content.Context import androidx.credentials.CredentialManager @@ -13,14 +13,14 @@ import java.time.LocalDate class CredentialRegisterManagerAuthTest { private lateinit var credentialRegisterManagerAuth: CredentialRegisterManagerAuth - private val firebaseAuthManager: FirebaseAuthManager = mockk() + private val authenticationManager: AuthenticationManager = mockk() private val credentialManager: CredentialManager = mockk() private val context: Context = mockk() @Before fun setUp() { credentialRegisterManagerAuth = CredentialRegisterManagerAuth( - firebaseAuthManager, + authenticationManager, credentialManager, context ) @@ -36,9 +36,9 @@ class CredentialRegisterManagerAuthTest { val selectedGender = "Male" val dateOfBirth = LocalDate.now() - coEvery { firebaseAuthManager.linkUserToGoogleAccount(idToken) } returns true + coEvery { authenticationManager.linkUserToGoogleAccount(idToken) } returns true coEvery { - firebaseAuthManager.saveUserData( + authenticationManager.saveUserData( firstName, lastName, email, @@ -73,7 +73,7 @@ class CredentialRegisterManagerAuthTest { val dateOfBirth = LocalDate.now() coEvery { - firebaseAuthManager.signUpWithEmailAndPassword( + authenticationManager.signUpWithEmailAndPassword( email, password, firstName, diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt new file mode 100644 index 000000000..0d8888cc2 --- /dev/null +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/manager/UserSessionManagerTest.kt @@ -0,0 +1,206 @@ +package edu.stanford.spezi.module.account.manager + +import com.google.android.gms.tasks.Task +import com.google.common.truth.Truth.assertThat +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.storage.FirebaseStorage +import com.google.firebase.storage.StorageMetadata +import com.google.firebase.storage.StorageReference +import com.google.firebase.storage.StorageTask +import com.google.firebase.storage.UploadTask +import edu.stanford.spezi.core.testing.SpeziTestScope +import edu.stanford.spezi.core.testing.runTestUnconfined +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Test + +class UserSessionManagerTest { + private val firebaseStorage: FirebaseStorage = mockk() + private val firebaseAuth: FirebaseAuth = mockk() + + private lateinit var authStateListener: AuthStateListener + private lateinit var userSessionManager: UserSessionManager + + @Test + fun `it should register auth state listener on init`() { + // when + createUserSessionManager() + + // then + verify { firebaseAuth.addAuthStateListener(any()) } + } + + @Test + fun `it should have not initialized user state on init if no listener callback is received`() { + // given + createUserSessionManager() + + // when + val state = userSessionManager.userState.value + + // then + assertThat(state).isEqualTo(UserState.NotInitialized) + } + + @Test + fun `it should reflect not updated user state when invoked with null user callback`() = + runTestUnconfined { + // given + every { firebaseAuth.currentUser } returns null + createUserSessionManager() + + // when + authStateListener.onAuthStateChanged(firebaseAuth) + + // then + assertThat(userSessionManager.userState.value).isEqualTo(UserState.NotInitialized) + } + + @Test + fun `it should reflect the correct user state when invoked with anonymous user callback`() = + runTestUnconfined { + // given + val firebaseUser: FirebaseUser = mockk { + every { isAnonymous } returns true + } + every { firebaseAuth.currentUser } returns firebaseUser + createUserSessionManager() + val expectedUserState = UserState.Anonymous + + // when + authStateListener.onAuthStateChanged(firebaseAuth) + + // then + assertThat(userSessionManager.userState.value).isEqualTo(expectedUserState) + } + + @Test + fun `it should reflect registered user without uid state correctly`() = + runTestUnconfined { + // given + val firebaseUser: FirebaseUser = mockk { + every { isAnonymous } returns false + } + every { firebaseAuth.currentUser } returns firebaseUser + every { firebaseAuth.uid } returns null + createUserSessionManager() + val expectedUserState = UserState.Registered(hasConsented = false) + + // when + authStateListener.onAuthStateChanged(firebaseAuth) + + // then + assertThat(userSessionManager.userState.value).isEqualTo(expectedUserState) + } + + @Test + fun `it should reflect registered user consented state correctly`() = + runTestUnconfined { + // given + val uid = "uid" + val location = "users/$uid/signature.pdf" + val firebaseUser: FirebaseUser = mockk { + every { isAnonymous } returns false + } + val storageReference: StorageReference = mockk() + val metadataTask: Task = mockk { + every { isComplete } returns true + every { isCanceled } returns false + every { exception } returns null + every { result } returns mockk() + } + every { storageReference.metadata } returns metadataTask + every { firebaseAuth.currentUser } returns firebaseUser + every { firebaseAuth.uid } returns uid + every { firebaseStorage.getReference(location) } returns storageReference + createUserSessionManager() + val expectedUserState = UserState.Registered(hasConsented = true) + + // when + authStateListener.onAuthStateChanged(firebaseAuth) + + // then + assertThat(userSessionManager.userState.value).isEqualTo(expectedUserState) + } + + @Test + fun `it should not upload consent pdf if current user is not available`() = runTestUnconfined { + // given + every { firebaseAuth.currentUser } returns null + createUserSessionManager() + + // when + val result = userSessionManager.uploadConsentPdf(byteArrayOf()) + + // then + assertThat(result.isFailure).isTrue() + } + + @Test + fun `it should handle successful upload of consent pdf correctly`() = runTestUnconfined { + // given + setupPDFUpload(successful = true) + createUserSessionManager() + + // when + val result = userSessionManager.uploadConsentPdf(byteArrayOf()) + + // then + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `it should handle non successful upload consent pdf correctly`() = runTestUnconfined { + // given + setupPDFUpload(successful = false) + createUserSessionManager() + + // when + val result = userSessionManager.uploadConsentPdf(byteArrayOf()) + + // then + assertThat(result.isFailure).isTrue() + assertThat(userSessionManager.userState.value).isEqualTo(UserState.NotInitialized) + } + + private fun setupPDFUpload(successful: Boolean) { + val uid = "uid" + val location = "users/$uid/signature.pdf" + val firebaseUser: FirebaseUser = mockk() + every { firebaseUser.uid } returns uid + every { firebaseAuth.currentUser } returns firebaseUser + + val taskResult: UploadTask.TaskSnapshot = mockk() + val storageTask: StorageTask = mockk() + every { storageTask.isSuccessful } returns successful + val uploadTask: UploadTask = mockk { + every { isComplete } returns true + every { isCanceled } returns false + every { exception } returns null + every { result } returns taskResult + every { result.task } returns storageTask + } + val storageReference: StorageReference = mockk() + every { storageReference.putStream(any()) } returns uploadTask + every { firebaseStorage.getReference(location) } returns storageReference + } + + private fun createUserSessionManager() { + val slot = slot() + every { firebaseAuth.addAuthStateListener(capture(slot)) } just Runs + userSessionManager = UserSessionManager( + firebaseStorage = firebaseStorage, + firebaseAuth = firebaseAuth, + ioDispatcher = UnconfinedTestDispatcher(), + coroutineScope = SpeziTestScope(), + ) + authStateListener = slot.captured + } +} diff --git a/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterViewModelTest.kt b/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterViewModelTest.kt index eba0cdfe7..a33234184 100644 --- a/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterViewModelTest.kt +++ b/modules/account/src/test/java/edu/stanford/spezi/module/account/register/RegisterViewModelTest.kt @@ -4,7 +4,7 @@ import com.google.common.truth.Truth.assertThat import edu.stanford.spezi.core.testing.runTestUnconfined import edu.stanford.spezi.core.utils.MessageNotifier import edu.stanford.spezi.module.account.AccountEvents -import edu.stanford.spezi.module.account.cred.manager.CredentialRegisterManagerAuth +import edu.stanford.spezi.module.account.manager.CredentialRegisterManagerAuth import io.mockk.mockk import org.junit.Before import org.junit.Test diff --git a/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/EducationScreen.kt b/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/EducationScreen.kt index 441d54378..b4288c929 100644 --- a/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/EducationScreen.kt +++ b/modules/education/src/main/java/edu/stanford/spezi/modules/education/videos/EducationScreen.kt @@ -74,6 +74,7 @@ internal fun VideoItem(video: Video, onVideoClick: () -> Unit) { modifier = Modifier .clickable { onVideoClick() } .height(IMAGE_HEIGHT.dp) + .padding(Spacings.small) .fillMaxWidth(), model = video.thumbnailUrl, diff --git a/modules/onboarding/build.gradle.kts b/modules/onboarding/build.gradle.kts index 60bab4bfb..0bd7472e2 100644 --- a/modules/onboarding/build.gradle.kts +++ b/modules/onboarding/build.gradle.kts @@ -9,6 +9,7 @@ android { } dependencies { + implementation(project(":modules:account")) implementation(project(":core:coroutines")) implementation(project(":core:navigation")) implementation(project(":core:utils")) @@ -17,11 +18,6 @@ dependencies { implementation(libs.accompanist.pager) implementation(libs.hilt.navigation.compose) - implementation(libs.firebase.auth.ktx) - implementation(libs.firebase.firestore.ktx) - implementation(libs.firebase.functions.ktx) - implementation(libs.firebase.storage.ktx) - testImplementation(libs.bundles.unit.testing) androidTestImplementation(libs.bundles.compose.androidTest) diff --git a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt index bc7454e37..027b5ed18 100644 --- a/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt +++ b/modules/onboarding/src/androidTest/kotlin/edu/stanford/spezi/module/onboarding/di/TestOnboardingModule.kt @@ -4,10 +4,10 @@ import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn +import edu.stanford.spezi.module.account.di.AccountModule +import edu.stanford.spezi.module.account.manager.InvitationAuthManager import edu.stanford.spezi.module.onboarding.consent.ConsentManager -import edu.stanford.spezi.module.onboarding.consent.PdfService import edu.stanford.spezi.module.onboarding.fakes.FakeOnboardingRepository -import edu.stanford.spezi.module.onboarding.invitation.InvitationAuthManager import edu.stanford.spezi.module.onboarding.invitation.InvitationCodeRepository import edu.stanford.spezi.module.onboarding.onboarding.OnboardingRepository import edu.stanford.spezi.module.onboarding.sequential.SequentialOnboardingRepository @@ -17,7 +17,7 @@ import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [OnboardingModule::class] + replaces = [AccountModule.Bindings::class] ) class TestOnboardingModule { @@ -25,10 +25,6 @@ class TestOnboardingModule { @Singleton fun provideInvitationAuthManager(): InvitationAuthManager = mockk() - @Provides - @Singleton - fun providePdfService(): PdfService = mockk() - @Provides @Singleton fun provideOnboardingRepository( diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentManager.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentManager.kt index a24605119..832567de9 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentManager.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentManager.kt @@ -2,5 +2,6 @@ package edu.stanford.spezi.module.onboarding.consent interface ConsentManager { suspend fun getMarkdownText(): String - suspend fun onConsented(uiState: ConsentUiState): Result + suspend fun onConsented() + suspend fun onConsentFailure(error: Throwable) } diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModel.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModel.kt index 22cba39a0..2fbefdab2 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModel.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import edu.stanford.spezi.core.design.component.markdown.MarkdownParser -import edu.stanford.spezi.core.utils.MessageNotifier +import edu.stanford.spezi.module.account.manager.UserSessionManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -12,10 +12,11 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ConsentViewModel @Inject internal constructor( +internal class ConsentViewModel @Inject internal constructor( private val consentManager: ConsentManager, private val markdownParser: MarkdownParser, - private val messageNotifier: MessageNotifier, + private val pdfCreationService: PdfCreationService, + private val userSessionManager: UserSessionManager, ) : ViewModel() { private val _uiState = MutableStateFlow(ConsentUiState()) val uiState: StateFlow = _uiState @@ -30,37 +31,41 @@ class ConsentViewModel @Inject internal constructor( } fun onAction(action: ConsentAction) { - _uiState.update { currentState -> - when (action) { - is ConsentAction.TextFieldUpdate -> { - when (action.type) { - TextFieldType.FIRST_NAME -> { - currentState.copy(firstName = FieldState(value = action.newValue)) - } + when (action) { + is ConsentAction.TextFieldUpdate -> { + when (action.type) { + TextFieldType.FIRST_NAME -> { + val firstName = FieldState(value = action.newValue) + _uiState.update { it.copy(firstName = firstName) } + } - TextFieldType.LAST_NAME -> { - currentState.copy(lastName = FieldState(value = action.newValue)) - } + TextFieldType.LAST_NAME -> { + val lastName = FieldState(value = action.newValue) + _uiState.update { it.copy(lastName = lastName) } } } + } - is ConsentAction.AddPath -> { - currentState.copy(paths = currentState.paths + action.path) - } + is ConsentAction.AddPath -> { + _uiState.update { it.copy(paths = it.paths + action.path) } + } - is ConsentAction.Undo -> { - currentState.copy(paths = currentState.paths.dropLast(1)) - } + is ConsentAction.Undo -> { + _uiState.update { it.copy(paths = it.paths.dropLast(1)) } + } - is ConsentAction.Consent -> { - viewModelScope.launch { - consentManager.onConsented(currentState).onFailure { - messageNotifier.notify("Something went wrong, failed to submit the consent!") - } - } - currentState - } + is ConsentAction.Consent -> { + onConsentAction() } } } + + private fun onConsentAction() { + viewModelScope.launch { + val pdfBytes = pdfCreationService.createPdf(uiState = uiState.value) + userSessionManager.uploadConsentPdf(pdfBytes = pdfBytes) + .onSuccess { consentManager.onConsented() } + .onFailure { consentManager.onConsentFailure(error = it) } + } + } } diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/FirebasePdfService.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/FirebasePdfService.kt deleted file mode 100644 index 621e5cefe..000000000 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/FirebasePdfService.kt +++ /dev/null @@ -1,30 +0,0 @@ -package edu.stanford.spezi.module.onboarding.consent - -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.storage.FirebaseStorage -import edu.stanford.spezi.core.coroutines.di.Dispatching -import edu.stanford.spezi.core.logging.speziLogger -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream -import javax.inject.Inject - -internal class FirebasePdfService @Inject internal constructor( - private val firebaseStorage: FirebaseStorage, - private val firebaseAuth: FirebaseAuth, - @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, -) : PdfService { - private val logger by speziLogger() - - override suspend fun uploadPdf(pdfBytes: ByteArray): Result = withContext(ioDispatcher) { - runCatching { - firebaseAuth.uid?.let { uid -> - val inputStream = ByteArrayInputStream(pdfBytes) - logger.i { "Uploading file to Firebase Storage" } - firebaseStorage.getReference("users/$uid/signature.pdf") - .putStream(inputStream).await().task.isSuccessful - } ?: false - } - } -} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfCreationService.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfCreationService.kt index 28b0f7514..4b6312de1 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfCreationService.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfCreationService.kt @@ -17,7 +17,7 @@ import java.io.ByteArrayOutputStream import java.time.LocalDate import javax.inject.Inject -class PdfCreationService @Inject internal constructor( +internal class PdfCreationService @Inject internal constructor( @Dispatching.IO private val ioCoroutineDispatcher: CoroutineDispatcher, ) { diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfService.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfService.kt deleted file mode 100644 index 6b89e9988..000000000 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/consent/PdfService.kt +++ /dev/null @@ -1,6 +0,0 @@ -package edu.stanford.spezi.module.onboarding.consent - -interface PdfService { - - suspend fun uploadPdf(pdfBytes: ByteArray): Result -} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/di/OnboardingModule.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/di/OnboardingModule.kt deleted file mode 100644 index ddd484862..000000000 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/di/OnboardingModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package edu.stanford.spezi.module.onboarding.di - -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import edu.stanford.spezi.module.onboarding.consent.FirebasePdfService -import edu.stanford.spezi.module.onboarding.consent.PdfService -import edu.stanford.spezi.module.onboarding.invitation.FirebaseInvitationAuthManager -import edu.stanford.spezi.module.onboarding.invitation.InvitationAuthManager - -/** - * A Dagger module that provides dependencies for the onboarding feature. - */ -@Module -@InstallIn(SingletonComponent::class) -abstract class OnboardingModule { - - @Binds - internal abstract fun bindInvitationAuthManager( - firebaseInvitationAuthManager: FirebaseInvitationAuthManager, - ): InvitationAuthManager - - @Binds - internal abstract fun bindPdfService( - firebasePdfService: FirebasePdfService, - ): PdfService -} diff --git a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModel.kt b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModel.kt index 950ffabe7..23e4103f5 100644 --- a/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModel.kt +++ b/modules/onboarding/src/main/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModel.kt @@ -3,6 +3,7 @@ package edu.stanford.spezi.module.onboarding.invitation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import edu.stanford.spezi.module.account.manager.InvitationAuthManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update diff --git a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModelTest.kt b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModelTest.kt index ec67166cc..7533a7945 100644 --- a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModelTest.kt +++ b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/consent/ConsentViewModelTest.kt @@ -6,8 +6,9 @@ import edu.stanford.spezi.core.design.component.markdown.MarkdownElement import edu.stanford.spezi.core.design.component.markdown.MarkdownParser import edu.stanford.spezi.core.testing.CoroutineTestRule import edu.stanford.spezi.core.testing.runTestUnconfined -import edu.stanford.spezi.core.utils.MessageNotifier +import edu.stanford.spezi.module.account.manager.UserSessionManager import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.first @@ -22,12 +23,14 @@ class ConsentViewModelTest { private val consentManager: ConsentManager = mockk(relaxed = true) private val markdownParser: MarkdownParser = mockk(relaxed = true) - private val messageNotifier: MessageNotifier = mockk(relaxed = true) + private val pdfCreationService: PdfCreationService = mockk(relaxed = true) + private val userSessionManager: UserSessionManager = mockk(relaxed = true) private val viewModel by lazy { ConsentViewModel( consentManager = consentManager, markdownParser = markdownParser, - messageNotifier = messageNotifier + pdfCreationService = pdfCreationService, + userSessionManager = userSessionManager ) } @@ -91,4 +94,33 @@ class ConsentViewModelTest { // Then assertThat(uiState.markdownElements).isEqualTo(elements) } + + @Test + fun `it should invoke handle consent action correctly on success case`() = runTestUnconfined { + // given + val pdfBytes = byteArrayOf() + coEvery { pdfCreationService.createPdf(viewModel.uiState.value) } returns pdfBytes + coEvery { userSessionManager.uploadConsentPdf(pdfBytes) } returns Result.success(Unit) + + // when + viewModel.onAction(action = ConsentAction.Consent) + + // then + coVerify { consentManager.onConsented() } + } + + @Test + fun `it should invoke handle consent action correctly on error case`() = runTestUnconfined { + // given + val error: Throwable = mockk() + val pdfBytes = byteArrayOf() + coEvery { pdfCreationService.createPdf(viewModel.uiState.value) } returns pdfBytes + coEvery { userSessionManager.uploadConsentPdf(pdfBytes) } returns Result.failure(error) + + // when + viewModel.onAction(action = ConsentAction.Consent) + + // then + coVerify { consentManager.onConsentFailure(error) } + } } diff --git a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModelTest.kt b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModelTest.kt index 0881fe75e..4035ca109 100644 --- a/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModelTest.kt +++ b/modules/onboarding/src/test/kotlin/edu/stanford/spezi/module/onboarding/invitation/InvitationCodeViewModelTest.kt @@ -2,6 +2,7 @@ package edu.stanford.spezi.module.onboarding.invitation import com.google.common.truth.Truth.assertThat import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.module.account.manager.InvitationAuthManager import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.first