diff --git a/firebase-ai/firebase-ai.gradle.kts b/firebase-ai/firebase-ai.gradle.kts index ba7f21b56fb..d935fae55f4 100644 --- a/firebase-ai/firebase-ai.gradle.kts +++ b/firebase-ai/firebase-ai.gradle.kts @@ -123,6 +123,7 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.truth) testImplementation(libs.mockito.core) + testImplementation(libs.ktor.client.mock) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.junit) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt index 45e10114f72..424837280ef 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt @@ -113,7 +113,11 @@ internal constructor( val receivedJson = JSON.parseToJsonElement(receivedJsonStr) return if (receivedJson is JsonObject && "setupComplete" in receivedJson) { - LiveSession(session = webSession, blockingDispatcher = blockingDispatcher) + LiveSession( + session = webSession, + blockingDispatcher = blockingDispatcher, + firebaseApp = controller.firebaseApp + ) } else { webSession.close() throw ServiceConnectionHandshakeFailedException("Unable to connect to the server") diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt index f1f51dabab9..ec96e2941c2 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt @@ -97,7 +97,7 @@ internal constructor( private val requestOptions: RequestOptions, httpEngine: HttpClientEngine, private val apiClient: String, - private val firebaseApp: FirebaseApp, + internal val firebaseApp: FirebaseApp, private val appVersion: Int = 0, private val googleAppId: String, private val headerProvider: HeaderProvider?, diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt index 57a27f241a0..fc583030a45 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Exceptions.kt @@ -222,3 +222,6 @@ public class ServiceConnectionHandshakeFailedException(message: String, cause: T /** Catch all case for exceptions not explicitly expected. */ public class UnknownException internal constructor(message: String, cause: Throwable? = null) : FirebaseAIException(message, cause) + +/** A required permission is missing. */ +public class PermissionMissingException(message: String) : FirebaseAIException(message) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt index 1f84c18a53b..03ddb56cc59 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt @@ -17,10 +17,12 @@ package com.google.firebase.ai.type import android.Manifest.permission.RECORD_AUDIO +import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioTrack import android.util.Log import androidx.annotation.RequiresPermission +import com.google.firebase.FirebaseApp import com.google.firebase.ai.common.JSON import com.google.firebase.ai.common.util.CancelledCoroutineScope import com.google.firebase.ai.common.util.accumulateUntil @@ -58,7 +60,8 @@ public class LiveSession internal constructor( private val session: ClientWebSocketSession, @Blocking private val blockingDispatcher: CoroutineContext, - private var audioHelper: AudioHelper? = null + private var audioHelper: AudioHelper? = null, + private val firebaseApp: FirebaseApp, ) { /** * Coroutine scope that we batch data on for [startAudioConversation]. @@ -93,12 +96,20 @@ internal constructor( public suspend fun startAudioConversation( functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? = null ) { + val context = firebaseApp.applicationContext + if ( + context.checkSelfPermission(RECORD_AUDIO) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + throw PermissionMissingException("Missing RECORD_AUDIO permission.") + } + FirebaseAIException.catchAsync { if (scope.isActive) { Log.w( TAG, "startAudioConversation called after the recording has already started. " + - "Call stopAudioConversation to close the previous connection." + "Call stopAudioAudioConversation to close the previous connection." ) return@catchAsync } diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/type/LiveSessionTest.kt b/firebase-ai/src/test/java/com/google/firebase/ai/type/LiveSessionTest.kt new file mode 100644 index 00000000000..d64dfa58575 --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/type/LiveSessionTest.kt @@ -0,0 +1,31 @@ +package com.google.firebase.ai.type + +import android.content.Context +import android.content.pm.PackageManager +import com.google.firebase.FirebaseApp +import com.google.firebase.ai.util.doBlocking +import io.kotest.assertions.throwables.shouldThrow +import io.ktor.client.plugins.websocket.testing.EmptyWebSockets +import kotlinx.coroutines.Dispatchers +import org.junit.Test +import org.mockito.Mockito + +class LiveSessionTest { + + @Test + fun `startAudioConversation without permission throws exception`() = doBlocking { + val mockContext = Mockito.mock(Context::class.java) + Mockito.`when`(mockContext.checkSelfPermission(android.Manifest.permission.RECORD_AUDIO)) + .thenReturn(PackageManager.PERMISSION_DENIED) + val mockFirebaseApp = Mockito.mock(FirebaseApp::class.java) + Mockito.`when`(mockFirebaseApp.applicationContext).thenReturn(mockContext) + val session = + LiveSession( + session = EmptyWebSockets.client.session, + blockingDispatcher = Dispatchers.IO, + firebaseApp = mockFirebaseApp + ) + + shouldThrow { session.startAudioConversation() } + } +}