diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4570ee3a..e87b1789e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidx-test-espresso-core = "androidx.test.espresso:espresso-core:3.4.0" androidx-test-espresso-intents = "androidx.test.espresso:espresso-intents:3.4.0" accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } compose-animation-animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" } @@ -59,6 +60,7 @@ truth = "com.google.truth:truth:1.1.3" compose-android = [ "accompanist-flowlayout", + "accompanist-permissions", "androidx-activity-compose", "androidx-lifecycle-compose", "androidx-navigation-compose", diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 55d0f04fb..9a4801922 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -50,6 +50,7 @@ tasks.withType().configureEach { kotlinOptions.freeCompilerArgs += "-opt-in=androidx.compose.animation.ExperimentalAnimationApi" kotlinOptions.freeCompilerArgs += "-opt-in=androidx.compose.animation.core.ExperimentalTransitionApi" + kotlinOptions.freeCompilerArgs += "-Xjvm-default=compatibility" } dependencies { diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/application/SpeechFacadeTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/application/SpeechFacadeTest.kt new file mode 100644 index 000000000..593b30b39 --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/application/SpeechFacadeTest.kt @@ -0,0 +1,37 @@ +package ch.epfl.sdp.mobile.test.application + +import ch.epfl.sdp.mobile.application.speech.SpeechFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade.RecognitionResult.* +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizer +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizerFactory +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuspendingSpeechRecognizerFactory +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpeechFacadeTest { + + @Test + fun given_suspendingRecognizer_when_recognizesThenCancels_then_terminatesWithoutException() = + runTest(UnconfinedTestDispatcher()) { + val facade = SpeechFacade(SuspendingSpeechRecognizerFactory) + val job = launch { facade.recognize() } + job.cancelAndJoin() + } + + @Test + fun given_failingRecognizer_when_recognizes_then_returnsErrorInternal() = runTest { + val facade = SpeechFacade(FailingSpeechRecognizerFactory) + assertThat(facade.recognize()).isEqualTo(Failure.Internal) + } + + @Test + fun given_successfulRecognizer_when_recognizes_then_returnsResults() = runTest { + val facade = SpeechFacade(SuccessfulSpeechRecognizerFactory) + assertThat(facade.recognize()).isEqualTo(Success(SuccessfulSpeechRecognizer.Results)) + } +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/FailingSpeechRecognizerFactory.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/FailingSpeechRecognizerFactory.kt new file mode 100644 index 000000000..74ee09fdb --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/FailingSpeechRecognizerFactory.kt @@ -0,0 +1,22 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech + +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizer +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizerFactory + +/** An implementation of [SpeechRecognizerFactory] which always fails. */ +object FailingSpeechRecognizerFactory : SpeechRecognizerFactory { + override fun createSpeechRecognizer() = FailingSpeechRecognizer() +} + +/** A [SpeechRecognizer] which will always fail to recognize the user input, and return an error. */ +class FailingSpeechRecognizer : SpeechRecognizer { + private var listener: SpeechRecognizer.Listener? = null + override fun setListener(listener: SpeechRecognizer.Listener) { + this.listener = listener + } + override fun startListening() { + listener?.onError() + } + override fun stopListening() = Unit + override fun destroy() = Unit +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuccessfulSpeechRecognizerFactory.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuccessfulSpeechRecognizerFactory.kt new file mode 100644 index 000000000..0f2f9840b --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuccessfulSpeechRecognizerFactory.kt @@ -0,0 +1,29 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech + +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizer +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizerFactory + +/** An implementation of [SpeechRecognizerFactory] which always succeeds. */ +object SuccessfulSpeechRecognizerFactory : SpeechRecognizerFactory { + override fun createSpeechRecognizer() = SuccessfulSpeechRecognizer() +} + +/** A [SpeechRecognizer] which always succeeds to recognize the user input, and return [Results]. */ +class SuccessfulSpeechRecognizer : SpeechRecognizer { + + companion object { + + /** The results which will always be returned on success. */ + val Results = listOf("Hello", "World") + } + + private var listener: SpeechRecognizer.Listener? = null + override fun setListener(listener: SpeechRecognizer.Listener) { + this.listener = listener + } + override fun startListening() { + listener?.onResults(Results) + } + override fun stopListening() = Unit + override fun destroy() = Unit +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuspendingSpeechRecognizerFactory.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuspendingSpeechRecognizerFactory.kt new file mode 100644 index 000000000..5dd5e1b7c --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/SuspendingSpeechRecognizerFactory.kt @@ -0,0 +1,17 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech + +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizer +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizerFactory + +/** An implementation of [SpeechRecognizerFactory] which never issues results. */ +object SuspendingSpeechRecognizerFactory : SpeechRecognizerFactory { + override fun createSpeechRecognizer(): SpeechRecognizer = SuspendingSpeechRecognizer +} + +/** A [SpeechRecognizer] which never returns. */ +object SuspendingSpeechRecognizer : SpeechRecognizer { + override fun setListener(listener: SpeechRecognizer.Listener) = Unit + override fun startListening() = Unit // Never triggers a result. + override fun stopListening() = Unit + override fun destroy() = Unit +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerFactoryTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerFactoryTest.kt new file mode 100644 index 000000000..b4de1545a --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerFactoryTest.kt @@ -0,0 +1,27 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech.android + +import android.content.Context +import android.speech.SpeechRecognizer +import ch.epfl.sdp.mobile.infrastructure.speech.android.AndroidSpeechRecognizerFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Test + +class AndroidSpeechRecognizerFactoryTest { + + @Test + fun given_factory_when_createSpeechRecognizer_then_createsFrameworkSpeechRecognizer() { + val context = mockk() + val factory = AndroidSpeechRecognizerFactory(context) + val recognizer = mockk() + mockkStatic(SpeechRecognizer::class) + + every { SpeechRecognizer.createSpeechRecognizer(context) } returns recognizer + + factory.createSpeechRecognizer() + + verify { SpeechRecognizer.createSpeechRecognizer(context) } + } +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerTest.kt new file mode 100644 index 000000000..08cddad36 --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/AndroidSpeechRecognizerTest.kt @@ -0,0 +1,100 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech.android + +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import android.speech.SpeechRecognizer.RESULTS_RECOGNITION +import androidx.core.os.bundleOf +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizer.Listener +import ch.epfl.sdp.mobile.infrastructure.speech.android.AndroidSpeechRecognizer +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class AndroidSpeechRecognizerTest { + + @Test + fun given_recognizer_when_destroy_then_destroysFramework() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + every { framework.destroy() } returns Unit + + recognizer.destroy() + + verify { framework.destroy() } + } + + @Test + fun given_recognizer_when_startListening_then_startsFrameworkListening() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + every { framework.startListening(any()) } returns Unit + + recognizer.startListening() + + verify { framework.startListening(any()) } + } + + @Test + fun given_recognizer_when_stopListening_then_stopsFrameworkListening() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + every { framework.stopListening() } returns Unit + + recognizer.stopListening() + + verify { framework.stopListening() } + } + + @Test + fun given_recognizer_when_settingListener_then_callsErrorListener() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + val listener = mockk() + + every { listener.onError() } returns Unit + every { framework.setRecognitionListener(any()) } answers + { + firstArg().onError(0) + } + + recognizer.setListener(listener) + + verify { listener.onError() } + } + + @Test + fun given_recognizer_when_settingListener_then_callsSuccessListener() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + val listener = mockk() + + every { listener.onResults(arrayListOf("Hello")) } returns Unit + every { framework.setRecognitionListener(any()) } answers + { + firstArg() + .onResults(bundleOf(RESULTS_RECOGNITION to arrayListOf("Hello"))) + } + + recognizer.setListener(listener) + + verify { listener.onResults(arrayListOf("Hello")) } + } + + @Test + fun given_recognizer_when_settingListener_then_callsSuccessListenerWithDefaultValue() { + val framework = mockk() + val recognizer = AndroidSpeechRecognizer(framework) + val listener = mockk() + + every { listener.onResults(emptyList()) } returns Unit + every { framework.setRecognitionListener(any()) } answers + { + firstArg().onResults(bundleOf(RESULTS_RECOGNITION to null)) + } + + recognizer.setListener(listener) + + verify { listener.onResults(emptyList()) } + } +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/RecognitionListenerAdapterTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/RecognitionListenerAdapterTest.kt new file mode 100644 index 000000000..bb383de56 --- /dev/null +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/infrastructure/speech/android/RecognitionListenerAdapterTest.kt @@ -0,0 +1,25 @@ +package ch.epfl.sdp.mobile.test.infrastructure.speech.android + +import android.os.Bundle +import ch.epfl.sdp.mobile.infrastructure.speech.android.RecognitionListenerAdapter +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecognitionListenerAdapterTest { + + @Test + fun given_emptyAdapter_when_callingAnyMethod_then_neverThrowsAndAlwaysReturnsUnit() { + // This is slightly artificial, but we don't really expect anything else from the subject under + // test and it's required for code coverage. + val adapter = object : RecognitionListenerAdapter() {} + + assertThat(adapter.onReadyForSpeech(Bundle.EMPTY)).isEqualTo(Unit) + assertThat(adapter.onBeginningOfSpeech()).isEqualTo(Unit) + assertThat(adapter.onRmsChanged(0f)).isEqualTo(Unit) + assertThat(adapter.onBufferReceived(byteArrayOf())).isEqualTo(Unit) + assertThat(adapter.onEndOfSpeech()).isEqualTo(Unit) + assertThat(adapter.onResults(Bundle.EMPTY)).isEqualTo(Unit) + assertThat(adapter.onPartialResults(Bundle.EMPTY)).isEqualTo(Unit) + assertThat(adapter.onEvent(0, Bundle.EMPTY)).isEqualTo(Unit) + } +} diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/AuthenticatedUserProfileScreenStateTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/AuthenticatedUserProfileScreenStateTest.kt index a03f7f851..698cd70ce 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/AuthenticatedUserProfileScreenStateTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/AuthenticatedUserProfileScreenStateTest.kt @@ -7,10 +7,12 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulSettingsScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizerFactory import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -36,9 +38,10 @@ class AuthenticatedUserProfileScreenStateTest { val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) val authenticationFacade = AuthenticationFacade(auth, store) + val speechFacade = SpeechFacade(SuccessfulSpeechRecognizerFactory) rule.setContent { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulSettingsScreen(mockUser, {}, {}, {}) } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/ClassicChessBoardStateTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/ClassicChessBoardStateTest.kt index 1cf56e961..39fb4a9f2 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/ClassicChessBoardStateTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/ClassicChessBoardStateTest.kt @@ -10,6 +10,7 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.ui.game.ChessBoardState +import ch.epfl.sdp.mobile.ui.game.SpeechRecognizerState import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk @@ -20,6 +21,11 @@ import org.junit.Test class ClassicChessBoardStateTest { + private object NoOpSpeechRecognizerState : SpeechRecognizerState { + override val listening = false + override fun onListenClick() = Unit + } + @Test fun selectingPiece_displaysAvailableMoves() = runTest { val auth = emptyAuth() @@ -40,7 +46,7 @@ class ClassicChessBoardStateTest { val match = facade.createMatch(user, user) val actions = StatefulGameScreenActions(onBack = {}, onShowAr = {}) - val state = MatchGameScreenState(actions, user, match, scope) + val state = MatchGameScreenState(actions, user, match, scope, NoOpSpeechRecognizerState) state.onPositionClick(ChessBoardState.Position(4, 6)) assertThat(state.availableMoves) diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/CompositionLocalsTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/CompositionLocalsTest.kt index 844fae357..a60508dd2 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/CompositionLocalsTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/CompositionLocalsTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import ch.epfl.sdp.mobile.state.LocalAuthenticationFacade import ch.epfl.sdp.mobile.state.LocalChessFacade import ch.epfl.sdp.mobile.state.LocalSocialFacade +import ch.epfl.sdp.mobile.state.LocalSpeechFacade import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test @@ -30,4 +31,11 @@ class CompositionLocalsTest { rule.setContent { LocalSocialFacade.current } } } + + @Test + fun missingSpeechFacade_throwsException() { + assertThrows(IllegalStateException::class.java) { + rule.setContent { LocalSpeechFacade.current } + } + } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/NavigationTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/NavigationTest.kt index e032d51e4..c4d04bff1 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/NavigationTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/NavigationTest.kt @@ -10,6 +10,7 @@ import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.Navigation import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.SuspendingAuth @@ -18,6 +19,7 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -32,8 +34,9 @@ class NavigationTest { val facade = AuthenticationFacade(SuspendingAuth, store) val socialFacade = SocialFacade(SuspendingAuth, store) val chessFacade = ChessFacade(SuspendingAuth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) rule.setContentWithLocalizedStrings { - ProvideFacades(facade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(facade, socialFacade, chessFacade, speechFacade) { Navigation() } } rule.onAllNodes(keyIsDefined(SemanticsProperties.Text)).assertCountEquals(0) } @@ -45,9 +48,10 @@ class NavigationTest { val facade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(SuspendingAuth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(facade, socialFacade, chessFacade, speechFacade) { Navigation() } } // Do we see the authentication screen actions ? @@ -61,9 +65,10 @@ class NavigationTest { val facade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(SuspendingAuth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(facade, socialFacade, chessFacade, speechFacade) { Navigation() } } facade.signUpWithEmail("email@epfl.ch", "name", "password") @@ -82,9 +87,10 @@ class NavigationTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { Navigation() } } authFacade.signInWithEmail("email@epfl.ch", "password") diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulArScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulArScreenTest.kt index 7a93e316d..57f6dc114 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulArScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulArScreenTest.kt @@ -10,12 +10,14 @@ import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.HomeActivity import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulArScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import org.junit.Rule import org.junit.Test @@ -40,10 +42,11 @@ class StatefulArScreenTest { val authApi = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authApi, social, chess) { StatefulArScreen("gameId") } + ProvideFacades(authApi, social, chess, speech) { StatefulArScreen("gameId") } } rule.onNodeWithContentDescription(strings.arContentDescription).assertExists() diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileImageDialogTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileImageDialogTest.kt index 1fae3cd27..f689a7d9a 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileImageDialogTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileImageDialogTest.kt @@ -7,11 +7,13 @@ import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.Navigation import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import ch.epfl.sdp.mobile.ui.setting.Emojis import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -36,12 +38,13 @@ class StatefulEditProfileImageDialogTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { Navigation() } } rule.onNodeWithText(strings.sectionSettings).performClick() @@ -66,12 +69,13 @@ class StatefulEditProfileImageDialogTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { Navigation() } } rule.onNodeWithText(strings.sectionSettings).performClick() diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileNameDialogTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileNameDialogTest.kt index eede28b65..138b4a37d 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileNameDialogTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulEditProfileNameDialogTest.kt @@ -6,11 +6,13 @@ import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.Navigation import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -29,12 +31,13 @@ class StatefulEditProfileNameDialogTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { Navigation() } } rule.onNodeWithText(strings.sectionSettings).performClick() @@ -54,12 +57,13 @@ class StatefulEditProfileNameDialogTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { Navigation() } + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { Navigation() } } rule.onNodeWithText(strings.sectionSettings).performClick() diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulFollowingScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulFollowingScreenTest.kt index 0c854da09..ce36cb983 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulFollowingScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulFollowingScreenTest.kt @@ -10,6 +10,7 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.infrastructure.persistence.store.asFlow import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulFollowingScreen @@ -17,6 +18,7 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import com.google.common.truth.Truth.* import io.mockk.every import io.mockk.mockk @@ -50,13 +52,17 @@ class StatefulFollowingScreenTest { val mockSocialFacade = mockk() val mockAuthenticationFacade = mockk() val mockChessFacade = mockk() + val mockSpeechFacade = SpeechFacade(FailingSpeechRecognizerFactory) every { mockSocialFacade.search("", mockUser) } returns emptyFlow() rule.setContent { - ProvideFacades(mockAuthenticationFacade, mockSocialFacade, mockChessFacade) { - StatefulFollowingScreen(mockUser, {}) - } + ProvideFacades( + mockAuthenticationFacade, + mockSocialFacade, + mockChessFacade, + mockSpeechFacade, + ) { StatefulFollowingScreen(mockUser, {}) } } rule.onNodeWithText("Hans Peter").assertExists() } @@ -72,12 +78,13 @@ class StatefulFollowingScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authenticationFacade.signUpWithEmail("example@epfl.ch", "name", "password") val user = authenticationFacade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulFollowingScreen(user, {}) } } @@ -105,12 +112,13 @@ class StatefulFollowingScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authenticationFacade.signUpWithEmail("example@epfl.ch", "name", "password") val user = authenticationFacade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulFollowingScreen(user, {}) } } @@ -138,6 +146,7 @@ class StatefulFollowingScreenTest { authentication = remember { AuthenticationFacade(auth, store) }, social = remember { SocialFacade(auth, store) }, chess = remember { ChessFacade(auth, store) }, + speech = remember { SpeechFacade(FailingSpeechRecognizerFactory) }, ) { StatefulFollowingScreen(user, onShowProfileClick = {}) } } @@ -162,6 +171,7 @@ class StatefulFollowingScreenTest { authentication = remember { AuthenticationFacade(auth, store) }, social = remember { SocialFacade(auth, store) }, chess = remember { ChessFacade(auth, store) }, + speech = remember { SpeechFacade(FailingSpeechRecognizerFactory) }, ) { StatefulFollowingScreen(user, onShowProfileClick = {}) } } @@ -193,6 +203,7 @@ class StatefulFollowingScreenTest { authentication = remember { AuthenticationFacade(auth, store) }, social = remember { SocialFacade(auth, store) }, chess = remember { ChessFacade(auth, store) }, + speech = remember { SpeechFacade(FailingSpeechRecognizerFactory) }, ) { StatefulFollowingScreen(user, onShowProfileClick = {}) } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulGameScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulGameScreenTest.kt index 52497ad4e..f627a8a0b 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulGameScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulGameScreenTest.kt @@ -1,7 +1,14 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + package ch.epfl.sdp.mobile.test.state +import android.Manifest.permission.RECORD_AUDIO +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.rule.GrantPermissionRule import ch.epfl.sdp.mobile.application.ChessDocument import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser @@ -9,6 +16,8 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.chess.engine.Rank import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizerFactory import ch.epfl.sdp.mobile.state.* import ch.epfl.sdp.mobile.state.game.MatchChessBoardState.Companion.toEngineRank import ch.epfl.sdp.mobile.state.game.MatchChessBoardState.Companion.toRank @@ -19,6 +28,10 @@ import ch.epfl.sdp.mobile.test.application.chess.engine.Games.promote import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizer +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizerFactory +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuspendingSpeechRecognizerFactory import ch.epfl.sdp.mobile.test.ui.game.ChessBoardRobot import ch.epfl.sdp.mobile.test.ui.game.click import ch.epfl.sdp.mobile.test.ui.game.drag @@ -27,6 +40,8 @@ import ch.epfl.sdp.mobile.ui.game.ChessBoardState import ch.epfl.sdp.mobile.ui.game.ChessBoardState.Color.Black import ch.epfl.sdp.mobile.ui.game.ChessBoardState.Color.White import ch.epfl.sdp.mobile.ui.game.ChessBoardState.Rank.* +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState import com.google.common.truth.Truth.assertThat import io.mockk.every import io.mockk.mockk @@ -43,9 +58,13 @@ class StatefulGameScreenTest { * playing against himself * * @param actions the [StatefulGameScreenActions] for this composable. + * @param recognizer the [SpeechRecognizerFactory] used to make the speech request. + * @param audioPermission the [PermissionState] to access audio. */ private fun emptyGameAgainstOneselfRobot( actions: StatefulGameScreenActions = StatefulGameScreenActions(onBack = {}, onShowAr = {}), + recognizer: SpeechRecognizerFactory = SuspendingSpeechRecognizerFactory, + audioPermission: PermissionState = GrantedPermissionState, ): ChessBoardRobot { val auth = emptyAuth() val store = buildStore { @@ -58,13 +77,16 @@ class StatefulGameScreenTest { val authApi = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(recognizer) val user1 = mockk() every { user1.uid } returns "userId1" val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authApi, social, chess) { StatefulGameScreen(user1, "gameId", actions) } + ProvideFacades(authApi, social, chess, speech) { + StatefulGameScreen(user1, "gameId", actions, audioPermissionState = audioPermission) + } } return ChessBoardRobot(rule, strings) @@ -531,6 +553,7 @@ class StatefulGameScreenTest { val authApi = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) val user1 = mockk() every { user1.uid } returns "userId1" @@ -539,7 +562,9 @@ class StatefulGameScreenTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authApi, social, chess) { StatefulGameScreen(user1, "gameId", actions) } + ProvideFacades(authApi, social, chess, speech) { + StatefulGameScreen(user1, "gameId", actions) + } } val robot = ChessBoardRobot(rule, strings) @@ -564,6 +589,7 @@ class StatefulGameScreenTest { val authApi = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) val user1 = mockk() every { user1.uid } returns "userId1" @@ -572,7 +598,9 @@ class StatefulGameScreenTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authApi, social, chess) { StatefulGameScreen(user1, "gameId", actions) } + ProvideFacades(authApi, social, chess, speech) { + StatefulGameScreen(user1, "gameId", actions) + } } val robot = ChessBoardRobot(rule, strings) @@ -590,6 +618,8 @@ class StatefulGameScreenTest { robot.assertHasPiece(4, 1, Black, Pawn) } + @get:Rule val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(RECORD_AUDIO) + @Test fun clickingListening_showsListeningText() { val robot = emptyGameAgainstOneselfRobot() @@ -675,4 +705,71 @@ class StatefulGameScreenTest { robot.onNodeWithContentDescription(robot.strings.boardPieceQueen).performClick() robot.onNodeWithLocalizedText { robot.strings.gamePromoteConfirm }.assertIsNotEnabled() } + + @Test + fun given_successfulRecognizer_when_clicksListening_then_displaysRecognitionResults() { + // This will fail once we want to move the pieces instead. + val robot = + emptyGameAgainstOneselfRobot( + recognizer = SuccessfulSpeechRecognizerFactory, + audioPermission = GrantedPermissionState, + ) + robot.onNodeWithLocalizedContentDescription { gameMicOffContentDescription }.performClick() + robot.onNodeWithText(SuccessfulSpeechRecognizer.Results[0]).assertExists() + } + + @Test + fun given_failingRecognizer_when_clicksListening_then_displaysFailedRecognitionResults() { + // This will fail once we want to move the pieces instead. + val robot = + emptyGameAgainstOneselfRobot( + recognizer = FailingSpeechRecognizerFactory, + audioPermission = GrantedPermissionState, + ) + robot.onNodeWithLocalizedContentDescription { gameMicOffContentDescription }.performClick() + robot.onNodeWithText("Internal failure").assertExists() + } + + @Test + fun given_noPermission_when_clicksListening_then_requestsPermission() { + val permission = MissingPermissionState() + val robot = + emptyGameAgainstOneselfRobot( + recognizer = SuspendingSpeechRecognizerFactory, + audioPermission = permission, + ) + robot.onNodeWithLocalizedContentDescription { gameMicOffContentDescription }.performClick() + assertThat(permission.permissionRequested).isTrue() + } + + @Test + fun given_suspendingRecognizer_when_clickingListeningTwice_then_cancelsRecognition() { + val robot = + emptyGameAgainstOneselfRobot( + recognizer = SuspendingSpeechRecognizerFactory, + audioPermission = GrantedPermissionState, + ) + robot.onNodeWithLocalizedContentDescription { gameMicOffContentDescription }.performClick() + robot.onNodeWithLocalizedText { gameListening }.performClick() + robot.onNodeWithLocalizedContentDescription { gameMicOffContentDescription }.assertExists() + } +} + +private object GrantedPermissionState : PermissionState { + override val hasPermission = true + override val permission = RECORD_AUDIO + override val permissionRequested = true + override val shouldShowRationale = false + override fun launchPermissionRequest() = Unit +} + +private class MissingPermissionState : PermissionState { + override var permissionRequested by mutableStateOf(false) + override val permission = RECORD_AUDIO + override val hasPermission + get() = permissionRequested + override val shouldShowRationale = false + override fun launchPermissionRequest() { + permissionRequested = true + } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulHomeTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulHomeTest.kt index f30e456bb..969bc3181 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulHomeTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulHomeTest.kt @@ -14,6 +14,8 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade +import ch.epfl.sdp.mobile.state.Navigation import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulHome import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth @@ -21,6 +23,8 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory +import ch.epfl.sdp.mobile.test.infrastructure.speech.SuccessfulSpeechRecognizerFactory import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -40,13 +44,14 @@ class StatefulHomeTest { val api = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) api.signUpWithEmail("email@epfl.ch", "name", "password") val user = api.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(api, social, chess) { StatefulHome(user) } + ProvideFacades(api, social, chess, speech) { StatefulHome(user) } } rule.onNodeWithText(strings.sectionSocial).assertIsSelected() rule.onNodeWithText(strings.sectionSettings).assertIsNotSelected() @@ -59,12 +64,13 @@ class StatefulHomeTest { val api = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) api.signUpWithEmail("email@epfl.ch", "name", "password") val user = api.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(api, social, chess) { StatefulHome(user) } + ProvideFacades(api, social, chess, speech) { StatefulHome(user) } } rule.onNodeWithText(strings.sectionSettings).performClick() rule.onNodeWithText(strings.sectionSocial).assertIsNotSelected() @@ -78,13 +84,14 @@ class StatefulHomeTest { val api = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) api.signUpWithEmail("email@epfl.ch", "name", "password") val user = api.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(api, social, chess) { StatefulHome(user) } + ProvideFacades(api, social, chess, speech) { StatefulHome(user) } } rule.onNodeWithText(strings.sectionSocial).performClick() rule.onNodeWithText(strings.sectionSocial).assertIsSelected() @@ -98,12 +105,13 @@ class StatefulHomeTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("email@epfl.ch", "name", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { StatefulHome(user) } + ProvideFacades(facade, social, chess, speech) { StatefulHome(user) } } rule.onNodeWithText(strings.sectionPlay).performClick() rule.onNodeWithText(strings.sectionPlay).assertIsSelected() @@ -122,12 +130,13 @@ class StatefulHomeTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { StatefulHome(user) } + ProvideFacades(facade, social, chess, speech) { StatefulHome(user) } } rule.onNodeWithText(strings.sectionSocial).performClick() rule.onNodeWithText("testName").assertExists() @@ -142,6 +151,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("email@epfl.ch", "name", "password") val user = authFacade.currentUser.filterIsInstance().first() @@ -149,7 +159,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { val controller = rememberNavController() - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speech) { StatefulHome( user = user, // We must call controller.navigate() after the first composition (so the @@ -173,6 +183,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("user1@email", "user1", "password") val currentUser = authFacade.currentUser.filterIsInstance().first() @@ -182,7 +193,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, social, chess) { StatefulHome(currentUser) } + ProvideFacades(authFacade, social, chess, speech) { StatefulHome(currentUser) } } rule.onNodeWithText(strings.sectionPlay).performClick() @@ -202,6 +213,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("user1@email", "user1", "password") val currentUser = authFacade.currentUser.filterIsInstance().first() @@ -211,7 +223,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, social, chess) { StatefulHome(currentUser) } + ProvideFacades(authFacade, social, chess, speech) { StatefulHome(currentUser) } } rule.onNodeWithText(strings.sectionPlay).performClick() @@ -232,6 +244,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("user1@email", "user1", "password") val currentUser = authFacade.currentUser.filterIsInstance().first() @@ -241,7 +254,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, social, chess) { StatefulHome(currentUser) } + ProvideFacades(authFacade, social, chess, speech) { StatefulHome(currentUser) } } rule.onNodeWithText(strings.sectionPlay).performClick() @@ -264,6 +277,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("user1@email", "user1", "password") val currentUser = authFacade.currentUser.filterIsInstance().first() @@ -273,7 +287,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, social, chess) { StatefulHome(currentUser) } + ProvideFacades(authFacade, social, chess, speech) { StatefulHome(currentUser) } } rule.onNodeWithText(strings.sectionPlay).performClick() @@ -294,13 +308,14 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signUpWithEmail("user1@email", "user1", "password") val currentUser = authFacade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, social, chess) { StatefulHome(currentUser) } + ProvideFacades(authFacade, social, chess, speech) { StatefulHome(currentUser) } } rule.onNodeWithText(strings.sectionPlay).performClick() @@ -327,6 +342,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val user = authFacade.currentUser.filterIsInstance().first() @@ -334,7 +350,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { val controller = rememberNavController() - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speech) { StatefulHome( user = user, controller = controller, @@ -366,6 +382,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(SuccessfulSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val user = authFacade.currentUser.filterIsInstance().first() @@ -373,7 +390,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { val controller = rememberNavController() - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { StatefulHome( user = user, controller = controller, @@ -406,6 +423,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speechFacade = SpeechFacade(SuccessfulSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val user = authFacade.currentUser.filterIsInstance().first() @@ -415,7 +433,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { val controller = rememberNavController() - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { StatefulHome( user = user, controller = controller, @@ -449,6 +467,7 @@ class StatefulHomeTest { val authFacade = AuthenticationFacade(auth, store) val chessFacade = ChessFacade(auth, store) val socialFacade = SocialFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val user = authFacade.currentUser.filterIsInstance().first() @@ -456,7 +475,7 @@ class StatefulHomeTest { val strings = rule.setContentWithLocalizedStrings { val controller = rememberNavController() - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speech) { StatefulHome( user = user, controller = controller, @@ -470,4 +489,55 @@ class StatefulHomeTest { rule.onNodeWithContentDescription(strings.arContentDescription).assertExists() } } + + @Test + fun given_userIsLoggedIn_when_editProfileName_then_nameShouldBeUpdated() = runTest { + val auth = buildAuth { user("email@example.org", "password", "1") } + val store = buildStore { + collection("users") { document("1", ProfileDocument(name = "test", emoji = ":)")) } + } + + val authFacade = AuthenticationFacade(auth, store) + val chessFacade = ChessFacade(auth, store) + val socialFacade = SocialFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) + + authFacade.signInWithEmail("email@example.org", "password") + + val strings = + rule.setContentWithLocalizedStrings { + ProvideFacades(authFacade, socialFacade, chessFacade, speech) { Navigation() } + } + + rule.onNodeWithText(strings.sectionSettings).performClick() + rule.onNodeWithContentDescription(strings.profileEditNameIcon).performClick() + rule.onNode(hasText("test") and hasSetTextAction()).performTextInput("2") + rule.onNodeWithText(strings.settingEditSave).performClick() + rule.onNodeWithText("test2").assertIsDisplayed() + } + + @Test + fun given_userIsLoggedIn_when_editProfileName_then_cancelWithoutSave() = runTest { + val auth = buildAuth { user("email@example.org", "password", "1") } + val store = buildStore { + collection("users") { document("1", ProfileDocument("1", name = "test", emoji = ":)")) } + } + + val authFacade = AuthenticationFacade(auth, store) + val chessFacade = ChessFacade(auth, store) + val socialFacade = SocialFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) + + authFacade.signInWithEmail("email@example.org", "password") + + val strings = + rule.setContentWithLocalizedStrings { + ProvideFacades(authFacade, socialFacade, chessFacade, speech) { Navigation() } + } + + rule.onNodeWithText(strings.sectionSettings).performClick() + rule.onNodeWithContentDescription(strings.profileEditNameIcon).performClick() + rule.onNodeWithText(strings.settingEditCancel).performClick() + rule.onNodeWithText("test").assertIsDisplayed() + } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPlayScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPlayScreenTest.kt index 12fa5c350..1832cb89f 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPlayScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPlayScreenTest.kt @@ -9,6 +9,7 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulPlayScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth @@ -16,6 +17,7 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import com.google.common.truth.Truth import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filterIsInstance @@ -44,12 +46,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val userAuthenticated = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = userAuthenticated, onGameItemClick = {}, @@ -86,12 +89,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val userAuthenticated = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = userAuthenticated, onGameItemClick = {}, @@ -127,12 +131,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val userAuthenticated = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = userAuthenticated, onGameItemClick = {}, @@ -158,12 +163,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val userAuthenticated = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = userAuthenticated, onGameItemClick = {}, @@ -189,12 +195,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signInWithEmail("email@example.org", "password") val userAuthenticated = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = userAuthenticated, onGameItemClick = {}, @@ -214,12 +221,13 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("email@example.org", "test", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = user, onGameItemClick = {}, @@ -239,13 +247,14 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = currentUser, onGameItemClick = {}, @@ -269,6 +278,7 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -276,7 +286,7 @@ class StatefulPlayScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = currentUser, onGameItemClick = {}, @@ -305,6 +315,7 @@ class StatefulPlayScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -312,7 +323,7 @@ class StatefulPlayScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPlayScreen( user = currentUser, onGameItemClick = {}, diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPrepareGameScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPrepareGameScreenTest.kt index 4f93bc4c6..5f76ebe39 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPrepareGameScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulPrepareGameScreenTest.kt @@ -9,12 +9,14 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulPrepareGameScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.filterIsInstance @@ -35,12 +37,13 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("email@epfl.ch", "name", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = user, navigateToGame = {}, cancelClick = {}) } } @@ -56,12 +59,13 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("email@epfl.ch", "name", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = user, navigateToGame = {}, cancelClick = {}) } } @@ -77,12 +81,13 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("email@epfl.ch", "name", "password") val user = facade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = user, navigateToGame = {}, cancelClick = {}) } } @@ -101,6 +106,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val authUser1 = facade.currentUser.filterIsInstance().first() @@ -109,7 +115,7 @@ class StatefulPrepareGameScreenTest { authUser1.follow(user2) rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = authUser1, navigateToGame = {}, cancelClick = {}) } } @@ -126,6 +132,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -135,7 +142,7 @@ class StatefulPrepareGameScreenTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = currentUser, navigateToGame = {}, cancelClick = {}) } } @@ -157,6 +164,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -166,7 +174,7 @@ class StatefulPrepareGameScreenTest { val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = currentUser, navigateToGame = {}, cancelClick = {}) } } @@ -189,6 +197,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val authUser1 = facade.currentUser.filterIsInstance().first() @@ -197,7 +206,7 @@ class StatefulPrepareGameScreenTest { authUser1.follow(user2) rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen(user = authUser1, navigateToGame = {}, cancelClick = {}) } } @@ -216,6 +225,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -226,7 +236,7 @@ class StatefulPrepareGameScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen( user = currentUser, navigateToGame = { _ -> @@ -252,6 +262,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -262,7 +273,7 @@ class StatefulPrepareGameScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen( user = currentUser, navigateToGame = {}, @@ -287,6 +298,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -297,7 +309,7 @@ class StatefulPrepareGameScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen( user = currentUser, navigateToGame = { @@ -321,6 +333,7 @@ class StatefulPrepareGameScreenTest { val facade = AuthenticationFacade(auth, store) val social = SocialFacade(auth, store) val chess = ChessFacade(auth, store) + val speech = SpeechFacade(FailingSpeechRecognizerFactory) facade.signUpWithEmail("user1@email", "user1", "password") val currentUser = facade.currentUser.filterIsInstance().first() @@ -331,7 +344,7 @@ class StatefulPrepareGameScreenTest { val channel = Channel(capacity = 1) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(facade, social, chess) { + ProvideFacades(facade, social, chess, speech) { StatefulPrepareGameScreen( user = currentUser, navigateToGame = { diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulProfileScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulProfileScreenTest.kt index 715b5b2c8..8ee176ce6 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulProfileScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulProfileScreenTest.kt @@ -7,11 +7,13 @@ import ch.epfl.sdp.mobile.application.ProfileDocument import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulVisitedProfileScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -37,10 +39,11 @@ class StatefulProfileScreenTest { val authFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { StatefulVisitedProfileScreen("1", {}) } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulSettingsScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulSettingsScreenTest.kt index f09e10cac..cf5e534f4 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulSettingsScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/state/StatefulSettingsScreenTest.kt @@ -9,6 +9,7 @@ import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulSettingsScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth @@ -16,6 +17,7 @@ import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.buildStore import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.document import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import com.google.common.truth.Truth import io.mockk.every import io.mockk.mockk @@ -46,13 +48,14 @@ class StatefulSettingsScreenTest { val authFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) authFacade.signInWithEmail("email@example.org", "password") val user = authFacade.currentUser.filterIsInstance().first() val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { StatefulSettingsScreen(user, {}, {}, {}) } } @@ -80,10 +83,11 @@ class StatefulSettingsScreenTest { val authFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authFacade, socialFacade, chessFacade) { + ProvideFacades(authFacade, socialFacade, chessFacade, speechFacade) { StatefulSettingsScreen(user, {}, openProfileEditNameMock, {}) } } diff --git a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/ui/authentication/AuthenticationScreenTest.kt b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/ui/authentication/AuthenticationScreenTest.kt index 7365c0880..01557ec92 100644 --- a/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/ui/authentication/AuthenticationScreenTest.kt +++ b/mobile/src/androidTest/java/ch/epfl/sdp/mobile/test/ui/authentication/AuthenticationScreenTest.kt @@ -8,11 +8,13 @@ import androidx.compose.ui.test.junit4.createComposeRule import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.state.ProvideFacades import ch.epfl.sdp.mobile.state.StatefulAuthenticationScreen import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.buildAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.auth.emptyAuth import ch.epfl.sdp.mobile.test.infrastructure.persistence.store.emptyStore +import ch.epfl.sdp.mobile.test.infrastructure.speech.FailingSpeechRecognizerFactory import ch.epfl.sdp.mobile.test.state.setContentWithLocalizedStrings import ch.epfl.sdp.mobile.ui.authentication.AuthenticationScreen import ch.epfl.sdp.mobile.ui.authentication.AuthenticationScreenState @@ -63,9 +65,10 @@ class AuthenticationScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulAuthenticationScreen() } } @@ -84,9 +87,10 @@ class AuthenticationScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulAuthenticationScreen() } } @@ -105,9 +109,10 @@ class AuthenticationScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulAuthenticationScreen() } } @@ -126,9 +131,10 @@ class AuthenticationScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulAuthenticationScreen() } } @@ -147,9 +153,10 @@ class AuthenticationScreenTest { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(FailingSpeechRecognizerFactory) val strings = rule.setContentWithLocalizedStrings { - ProvideFacades(authenticationFacade, socialFacade, chessFacade) { + ProvideFacades(authenticationFacade, socialFacade, chessFacade, speechFacade) { StatefulAuthenticationScreen() } } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 426c8fc84..227376a01 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -2,6 +2,17 @@ + + + + + + + + + + ) : RecognitionResult + } + + /** + * Starts voice recognition, and returns the associated [RecognitionResult]. + * + * @return the [RecognitionResult] from the recognition request. + */ + suspend fun recognize(): RecognitionResult = suspendCancellableCoroutine { cont -> + val recognizer = factory.createSpeechRecognizer() + + /** Cleans up the recognizer. */ + fun cleanup() { + recognizer.stopListening() + recognizer.destroy() + } + + recognizer.setListener( + object : SpeechRecognizer.Listener { + override fun onError() { + cleanup() + cont.resume(Failure.Internal) + } + override fun onResults(results: List) { + cleanup() + cont.resume(Success(results)) + } + }, + ) + recognizer.startListening() + cont.invokeOnCancellation { cleanup() } + } +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizer.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizer.kt new file mode 100644 index 000000000..92faa5e4b --- /dev/null +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizer.kt @@ -0,0 +1,35 @@ +package ch.epfl.sdp.mobile.infrastructure.speech + +/** An interface providing access to the native [SpeechRecognizer] of the platform. */ +interface SpeechRecognizer { + + /** A listener which will be called when some new speech recognition results are available. */ + interface Listener { + + /** A callback method, called when there's an error during the recognition. */ + fun onError() + + /** + * A callback method, called with the list of results. + * + * @param results the [List] of speech recognition results, ordered by decreasing score. + */ + fun onResults(results: List) + } + + /** + * Sets the [Listener] for this [SpeechRecognizer]. + * + * @param listener the [Listener] which is set. + */ + fun setListener(listener: Listener) + + /** Starts listening with this [SpeechRecognizer]. */ + fun startListening() + + /** Stops listening with this [SpeechRecognizer]. */ + fun stopListening() + + /** Destroys the recognizer. */ + fun destroy() +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizerFactory.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizerFactory.kt new file mode 100644 index 000000000..aec182453 --- /dev/null +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/SpeechRecognizerFactory.kt @@ -0,0 +1,8 @@ +package ch.epfl.sdp.mobile.infrastructure.speech + +/** A factory which can create some [SpeechRecognizer] instances. */ +interface SpeechRecognizerFactory { + + /** Returns a new [SpeechRecognizer], which may be used to perform some voice recognition. */ + fun createSpeechRecognizer(): SpeechRecognizer +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/AndroidSpeechRecognizerFactory.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/AndroidSpeechRecognizerFactory.kt new file mode 100644 index 000000000..11933f3f8 --- /dev/null +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/AndroidSpeechRecognizerFactory.kt @@ -0,0 +1,72 @@ +package ch.epfl.sdp.mobile.infrastructure.speech.android + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognizerIntent.* +import android.speech.SpeechRecognizer as NativeSpeechRecognizer +import android.speech.SpeechRecognizer.RESULTS_RECOGNITION +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizer +import ch.epfl.sdp.mobile.infrastructure.speech.SpeechRecognizerFactory + +/** The default locale we'll be using for speech recognition. */ +private const val DefaultLanguage = "en-US" + +/** The default maximum number of results we'll receive from the system speech recognizer. */ +private const val DefaultResultsCount = 10 + +/** + * An implementation of a [SpeechRecognizerFactory] which is backed by [NativeSpeechRecognizer]s. + * + * @param context the [Context] which will be used to create the speech recognizers. + * @param language the default language to use. + * @param resultsCount the maximum results count for each result set. + */ +class AndroidSpeechRecognizerFactory( + private val context: Context, + private val language: String = DefaultLanguage, + private val resultsCount: Int = DefaultResultsCount, +) : SpeechRecognizerFactory { + + override fun createSpeechRecognizer(): SpeechRecognizer = + AndroidSpeechRecognizer( + recognizer = NativeSpeechRecognizer.createSpeechRecognizer(context), + language = language, + resultsCount = resultsCount, + ) +} + +/** + * An implementation of a [SpeechRecognizer] which is backed by a [NativeSpeechRecognizer]. + * + * @param recognizer the underlying [NativeSpeechRecognizer]. + * @param language the language code that we use for recognition. + * @param resultsCount the maximum count of results that we are interested in. + */ +class AndroidSpeechRecognizer( + private val recognizer: NativeSpeechRecognizer, + private val language: String = DefaultLanguage, + private val resultsCount: Int = DefaultResultsCount, +) : SpeechRecognizer { + + override fun setListener(listener: SpeechRecognizer.Listener) = + recognizer.setRecognitionListener( + object : RecognitionListenerAdapter() { + override fun onError(error: Int) = listener.onError() + override fun onResults( + results: Bundle?, + ) = listener.onResults(results?.getStringArrayList(RESULTS_RECOGNITION) ?: emptyList()) + }, + ) + + override fun startListening() = + recognizer.startListening( + Intent(ACTION_RECOGNIZE_SPEECH) + .putExtra(EXTRA_LANGUAGE, language) + .putExtra(EXTRA_MAX_RESULTS, resultsCount), + ) + + override fun stopListening() = recognizer.stopListening() + + override fun destroy() = recognizer.destroy() +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/RecognitionListenerAdapter.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/RecognitionListenerAdapter.kt new file mode 100644 index 000000000..551aa8784 --- /dev/null +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/infrastructure/speech/android/RecognitionListenerAdapter.kt @@ -0,0 +1,20 @@ +package ch.epfl.sdp.mobile.infrastructure.speech.android + +import android.os.Bundle +import android.speech.RecognitionListener + +/** + * An abstract adapter around the Android [RecognitionListener] interface which provides some + * default implementations for all the methods. + */ +abstract class RecognitionListenerAdapter : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) = Unit + override fun onBeginningOfSpeech() = Unit + override fun onRmsChanged(rmsdB: Float) = Unit + override fun onBufferReceived(buffer: ByteArray?) = Unit + override fun onEndOfSpeech() = Unit + override fun onError(error: Int) = Unit + override fun onResults(results: Bundle?) = Unit + override fun onPartialResults(partialResults: Bundle?) = Unit + override fun onEvent(eventType: Int, params: Bundle?) = Unit +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/state/CompositionLocals.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/state/CompositionLocals.kt index dcdec0b3d..f66c4430a 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/state/CompositionLocals.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/state/CompositionLocals.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.compositionLocalOf import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade /** A global composition local which provides access to an instance of [AuthenticationFacade]. */ val LocalAuthenticationFacade = @@ -17,23 +18,30 @@ val LocalSocialFacade = compositionLocalOf { error("Missing Social /** A global composition local which provides access to an instance of [ChessFacade]. */ val LocalChessFacade = compositionLocalOf { error("Missing Chess API.") } +/** A global composition local which provides access to an instance of [SpeechFacade]. */ +val LocalSpeechFacade = compositionLocalOf { error("Missing Speech Facade.") } + /** * Provides the given Faces through different [androidx.compose.runtime.CompositionLocal] values * available throughout the hierarchy. * * @param authentication the [AuthenticationFacade] that will be provided. * @param social the [SocialFacade] that will be provided. + * @param chess the [ChessFacade] that will be provided. + * @param speech the [SpeechFacade] that will be provided. */ @Composable fun ProvideFacades( authentication: AuthenticationFacade, social: SocialFacade, chess: ChessFacade, + speech: SpeechFacade, content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalAuthenticationFacade provides authentication, LocalSocialFacade provides social, LocalChessFacade provides chess, + LocalSpeechFacade provides speech, ) { content() } } diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/state/HomeActivity.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/state/HomeActivity.kt index 15602336d..06987b308 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/state/HomeActivity.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/state/HomeActivity.kt @@ -6,8 +6,10 @@ import androidx.activity.compose.setContent import ch.epfl.sdp.mobile.application.authentication.AuthenticationFacade import ch.epfl.sdp.mobile.application.chess.ChessFacade import ch.epfl.sdp.mobile.application.social.SocialFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade import ch.epfl.sdp.mobile.infrastructure.persistence.auth.firebase.FirebaseAuth import ch.epfl.sdp.mobile.infrastructure.persistence.store.firestore.FirestoreStore +import ch.epfl.sdp.mobile.infrastructure.speech.android.AndroidSpeechRecognizerFactory import ch.epfl.sdp.mobile.ui.PawniesTheme import com.google.firebase.auth.ktx.auth import com.google.firebase.firestore.ktx.firestore @@ -26,14 +28,17 @@ class HomeActivity : ComponentActivity() { val authenticationFacade = AuthenticationFacade(auth, store) val socialFacade = SocialFacade(auth, store) val chessFacade = ChessFacade(auth, store) + val speechFacade = SpeechFacade(AndroidSpeechRecognizerFactory(this)) setContent { PawniesTheme { ProvideLocalizedStrings { ProvideFacades( - authentication = authenticationFacade, social = socialFacade, chess = chessFacade) { - Navigation() - } + authentication = authenticationFacade, + social = socialFacade, + chess = chessFacade, + speech = speechFacade, + ) { Navigation() } } } } diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulGameScreen.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulGameScreen.kt index 698e6e8fe..e877bee34 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulGameScreen.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulGameScreen.kt @@ -1,17 +1,26 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + package ch.epfl.sdp.mobile.state +import android.Manifest.permission.RECORD_AUDIO +import androidx.compose.foundation.MutatePriority.UserInput +import androidx.compose.foundation.MutatorMutex import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.material.SnackbarHostState +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.application.chess.Match +import ch.epfl.sdp.mobile.application.speech.SpeechFacade +import ch.epfl.sdp.mobile.application.speech.SpeechFacade.RecognitionResult.Failure +import ch.epfl.sdp.mobile.application.speech.SpeechFacade.RecognitionResult.Success import ch.epfl.sdp.mobile.state.game.MatchGameScreenState -import ch.epfl.sdp.mobile.ui.game.ChessBoardState -import ch.epfl.sdp.mobile.ui.game.GameScreen -import ch.epfl.sdp.mobile.ui.game.PromoteDialog -import ch.epfl.sdp.mobile.ui.game.PromotionState +import ch.epfl.sdp.mobile.ui.game.* +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * The different navigation actions which may be performed by the [StatefulGameScreen]. @@ -32,6 +41,7 @@ data class StatefulGameScreenActions( * @param actions the [StatefulGameScreenActions] to perform. * @param modifier the [Modifier] for the composable. * @param paddingValues the [PaddingValues] for this composable. + * @param audioPermissionState the [PermissionState] which provides access to audio content. */ @Composable fun StatefulGameScreen( @@ -40,11 +50,24 @@ fun StatefulGameScreen( actions: StatefulGameScreenActions, modifier: Modifier = Modifier, paddingValues: PaddingValues = PaddingValues(), + audioPermissionState: PermissionState = rememberPermissionState(RECORD_AUDIO), ) { - val facade = LocalChessFacade.current + val chessFacade = LocalChessFacade.current + val speechFacade = LocalSpeechFacade.current + val scope = rememberCoroutineScope() - val match = remember(facade, id) { facade.match(id) } + val match = remember(chessFacade, id) { chessFacade.match(id) } + val snackbarHostState = remember { SnackbarHostState() } + val speechRecognizerState = + remember(audioPermissionState, speechFacade, snackbarHostState, scope) { + SnackbarSpeechRecognizerState( + permission = audioPermissionState, + facade = speechFacade, + snackbarHostState = snackbarHostState, + scope = scope, + ) + } val gameScreenState = remember(actions, user, match, scope) { MatchGameScreenState( @@ -52,6 +75,7 @@ fun StatefulGameScreen( user = user, match = match, scope = scope, + speechRecognizerState = speechRecognizerState, ) } @@ -61,6 +85,7 @@ fun StatefulGameScreen( state = gameScreenState, modifier = modifier, contentPadding = paddingValues, + snackbarHostState = snackbarHostState, ) } @@ -88,3 +113,53 @@ private fun StatefulPromoteDialog( ) } } + +/** + * An implementation of [SpeechRecognizerState] which will display the results in a + * [SnackbarHostState]. + * + * @param permission the [PermissionState] for the microphone permission. + * @param facade the [SpeechFacade] which is used. + * @param snackbarHostState the [SnackbarHostState] used to display some results. + * @param scope the [CoroutineScope] in which the actions are performed. + */ +class SnackbarSpeechRecognizerState +constructor( + private val permission: PermissionState, + private val facade: SpeechFacade, + private val snackbarHostState: SnackbarHostState, + private val scope: CoroutineScope, +) : SpeechRecognizerState { + + override var listening: Boolean by mutableStateOf(false) + private set + + /** + * A [MutatorMutex] which ensures that multiple speech recognition requests aren't performed + * simultaneously, and that clicking on the button again cancels the previous request. + */ + private val mutex = MutatorMutex() + + override fun onListenClick() { + scope.launch { + val willCancel = listening + mutex.mutate(UserInput) { + try { + if (willCancel) return@mutate + listening = true + if (!permission.hasPermission) { + permission.launchPermissionRequest() + } else { + when (val speech = facade.recognize()) { + // TODO : Display an appropriate message, otherwise act on the board. + Failure.Internal -> snackbarHostState.showSnackbar("Internal failure") + is Success -> for (result in speech.results) snackbarHostState.showSnackbar(result) + } + } + } finally { + listening = false + } + } + } + } +} diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulHome.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulHome.kt index 231f6566a..4ac841b48 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulHome.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/state/StatefulHome.kt @@ -10,6 +10,7 @@ import androidx.navigation.compose.* import ch.epfl.sdp.mobile.application.authentication.AuthenticatedUser import ch.epfl.sdp.mobile.ui.home.HomeScaffold import ch.epfl.sdp.mobile.ui.home.HomeSection +import com.google.accompanist.permissions.ExperimentalPermissionsApi /** The route associated to the social tab. */ private const val SocialRoute = "social" @@ -49,6 +50,7 @@ private const val ArRoute = "ar" * @param modifier the [Modifier] for this composable. * @param controller the [NavHostController] used to control the current destination. */ +@OptIn(ExperimentalPermissionsApi::class) @Composable fun StatefulHome( user: AuthenticatedUser, diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/state/game/MatchGameScreenState.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/state/game/MatchGameScreenState.kt index 9d5def282..4f8c18fc7 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/state/game/MatchGameScreenState.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/state/game/MatchGameScreenState.kt @@ -21,6 +21,7 @@ import ch.epfl.sdp.mobile.ui.game.GameScreenState import ch.epfl.sdp.mobile.ui.game.GameScreenState.Message import ch.epfl.sdp.mobile.ui.game.GameScreenState.Move import ch.epfl.sdp.mobile.ui.game.PromotionState +import ch.epfl.sdp.mobile.ui.game.SpeechRecognizerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -32,7 +33,8 @@ import kotlinx.coroutines.launch * @param user the currently authenticated user. * @param match the match to display. * @param scope a [CoroutineScope] keeping track of the state lifecycle. - * @param delegate the [MatchChessBoardState] to delegate to. + * @param chessBoardDelegate the [MatchChessBoardState] to delegate to. + * @param speechRecognizerDelegate the [SpeechRecognizerState] to delegate to */ class MatchGameScreenState private constructor( @@ -40,8 +42,13 @@ private constructor( private val user: AuthenticatedUser, private val match: Match, private val scope: CoroutineScope, - private val delegate: MatchChessBoardState, -) : GameScreenState, PromotionState, ChessBoardState by delegate { + private val chessBoardDelegate: MatchChessBoardState, + private val speechRecognizerDelegate: SpeechRecognizerState, +) : + GameScreenState, + PromotionState, + ChessBoardState by chessBoardDelegate, + SpeechRecognizerState by speechRecognizerDelegate { /** * Creates a new [MatchGameScreenState]. @@ -50,20 +57,15 @@ private constructor( * @param user the currently authenticated user. * @param match the match to display. * @param scope a [CoroutineScope] keeping track of the state lifecycle. + * @param speechRecognizerState the [SpeechRecognizerState] that speech recognition uses. */ constructor( actions: StatefulGameScreenActions, user: AuthenticatedUser, match: Match, scope: CoroutineScope, - ) : this(actions, user, match, scope, MatchChessBoardState(match, scope)) - - // TODO : Implement these things. - override var listening by mutableStateOf(false) - private set - override fun onListenClick() { - listening = !listening - } + speechRecognizerState: SpeechRecognizerState, + ) : this(actions, user, match, scope, MatchChessBoardState(match, scope), speechRecognizerState) override fun onArClick() = actions.onShowAr(match) @@ -84,7 +86,7 @@ private constructor( * @param color the [Color] of the player in the engine. */ private fun message(color: Color): Message { - return when (val step = delegate.game.nextStep) { + return when (val step = chessBoardDelegate.game.nextStep) { is NextStep.Checkmate -> if (step.winner == color) Message.None else Message.Checkmate is NextStep.MovePiece -> if (step.turn == color) if (step.inCheck) Message.InCheck else Message.YourTurn @@ -106,7 +108,7 @@ private constructor( // Display all the possible moves for all the pieces on the board. get() { val position = selectedPosition ?: return emptySet() - return delegate + return chessBoardDelegate .game .actions(Position(position.x, position.y)) .mapNotNull { it.from + it.delta } @@ -142,7 +144,7 @@ private constructor( // Hide the current selection. selectedPosition = null - val step = delegate.game.nextStep as? NextStep.MovePiece ?: return + val step = chessBoardDelegate.game.nextStep as? NextStep.MovePiece ?: return val currentPlayingId = when (step.turn) { @@ -153,7 +155,7 @@ private constructor( if (currentPlayingId == user.uid) { // TODO: Update game locally first, then verify upload was successful? val actions = - delegate + chessBoardDelegate .game .actions(Position(from.x, from.y)) .filter { it.from + it.delta == Position(to.x, to.y) } @@ -194,7 +196,7 @@ private constructor( to = Position(promotionTo.x, promotionTo.y), rank = rank.toEngineRank(), ) - val step = delegate.game.nextStep as? NextStep.MovePiece ?: return + val step = chessBoardDelegate.game.nextStep as? NextStep.MovePiece ?: return scope.launch { val newGame = step.move(action) match.update(newGame) @@ -207,5 +209,5 @@ private constructor( } override val moves: List - get() = delegate.game.toAlgebraicNotation().map(::Move) + get() = chessBoardDelegate.game.toAlgebraicNotation().map(::Move) } diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreen.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreen.kt index 802dda97a..cfaa48083 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreen.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.key +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -37,9 +38,11 @@ fun GameScreen( state: GameScreenState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { Scaffold( modifier = modifier, + scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState), topBar = { GameScreenTopBar( onBackClick = state::onBackClick, diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreenState.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreenState.kt index 8e08b5046..b7fa2aaa1 100644 --- a/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreenState.kt +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/GameScreenState.kt @@ -9,7 +9,8 @@ import androidx.compose.runtime.Stable * @param Piece the type of the pieces of the underlying [ChessBoardState]. */ @Stable -interface GameScreenState : MovableChessBoardState { +interface GameScreenState : + MovableChessBoardState, SpeechRecognizerState { /** * A class representing a [Move] that's been performed by one of the players. @@ -50,12 +51,6 @@ interface GameScreenState : MovableChessBoardStat /** A callback which will be invoked when the user clicks on the AR button. */ fun onArClick() - /** A [Boolean] which indicates if the device is currently listening to voice inputs. */ - val listening: Boolean - - /** A callback which will be invoked when the user clicks on the listening button. */ - fun onListenClick() - /** * A [List] of all the moves which have been performed by the user. Moves are ordered and should * be displayed as such. diff --git a/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/SpeechRecognizerState.kt b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/SpeechRecognizerState.kt new file mode 100644 index 000000000..634b14d88 --- /dev/null +++ b/mobile/src/main/java/ch/epfl/sdp/mobile/ui/game/SpeechRecognizerState.kt @@ -0,0 +1,14 @@ +package ch.epfl.sdp.mobile.ui.game + +import androidx.compose.runtime.Stable + +/** An interface which represents the state of the user */ +@Stable +interface SpeechRecognizerState { + + /** A [Boolean] which indicates if the device is currently listening to voice inputs. */ + val listening: Boolean + + /** A callback which will be invoked when the user clicks on the listening button. */ + fun onListenClick() +}