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 fe4cae0d187..3918ca90731 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 @@ -16,6 +16,7 @@ package com.google.firebase.ai +import android.content.Context import com.google.firebase.FirebaseApp import com.google.firebase.ai.common.APIController import com.google.firebase.ai.common.AppCheckHeaderProvider @@ -48,6 +49,7 @@ import kotlinx.serialization.json.JsonObject @PublicPreviewAPI public class LiveGenerativeModel internal constructor( + private val context: Context, private val modelName: String, @Blocking private val blockingDispatcher: CoroutineContext, private val config: LiveGenerationConfig? = null, @@ -69,6 +71,7 @@ internal constructor( appCheckTokenProvider: InteropAppCheckTokenProvider? = null, internalAuthProvider: InternalAuthProvider? = null, ) : this( + firebaseApp.applicationContext, modelName, blockingDispatcher, config, @@ -110,7 +113,11 @@ internal constructor( val receivedJson = JSON.parseToJsonElement(receivedJsonStr) return if (receivedJson is JsonObject && "setupComplete" in receivedJson) { - LiveSession(session = webSession, blockingDispatcher = blockingDispatcher) + LiveSession( + context = context, + session = webSession, + blockingDispatcher = blockingDispatcher + ) } else { webSession.close() throw ServiceConnectionHandshakeFailedException("Unable to connect to the server") diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt index 6e2ff67ca4d..5d7a28397c8 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt @@ -134,6 +134,10 @@ internal class UnknownException(message: String, cause: Throwable? = null) : internal class ContentBlockedException(message: String, cause: Throwable? = null) : FirebaseCommonAIException(message, cause) +/** The request is missing a permission that is required to perform the requested operation. */ +internal class PermissionMissingException(message: String, cause: Throwable? = null) : + FirebaseCommonAIException(message, cause) + internal fun makeMissingCaseException( source: String, ordinal: Int 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..207a451bb72 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 @@ -16,12 +16,17 @@ package com.google.firebase.ai.type +import android.Manifest import android.Manifest.permission.RECORD_AUDIO +import android.content.Context +import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioTrack +import android.os.Build import android.util.Log import androidx.annotation.RequiresPermission import com.google.firebase.ai.common.JSON +import com.google.firebase.ai.common.PermissionMissingException import com.google.firebase.ai.common.util.CancelledCoroutineScope import com.google.firebase.ai.common.util.accumulateUntil import com.google.firebase.ai.common.util.childJob @@ -56,6 +61,7 @@ import kotlinx.serialization.json.Json @OptIn(ExperimentalSerializationApi::class) public class LiveSession internal constructor( + private val context: Context, private val session: ClientWebSocketSession, @Blocking private val blockingDispatcher: CoroutineContext, private var audioHelper: AudioHelper? = null @@ -93,6 +99,15 @@ internal constructor( public suspend fun startAudioConversation( functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? = null ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if ( + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != + PackageManager.PERMISSION_GRANTED + ) { + throw PermissionMissingException("Missing RECORD_AUDIO") + } + } + FirebaseAIException.catchAsync { if (scope.isActive) { Log.w( 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..20e844d9c5e --- /dev/null +++ b/firebase-ai/src/test/java/com/google/firebase/ai/type/LiveSessionTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.ai.type + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.google.firebase.ai.common.PermissionMissingException +import io.ktor.client.plugins.websocket.ClientWebSocketSession +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class, PublicPreviewAPI::class) +@RunWith(MockitoJUnitRunner::class) +class LiveSessionTest { + + @Mock private lateinit var mockContext: Context + @Mock private lateinit var mockPackageManager: PackageManager + @Mock private lateinit var mockSession: ClientWebSocketSession + @Mock private lateinit var mockAudioHelper: AudioHelper + + private lateinit var mockedBuildVersion: MockedStatic + private lateinit var testDispatcher: CoroutineContext + private lateinit var liveSession: LiveSession + + @Before + fun setUp() { + testDispatcher = UnconfinedTestDispatcher() + `when`(mockContext.packageManager).thenReturn(mockPackageManager) + mockedBuildVersion = mockStatic(Build.VERSION::class.java) + + // Mock AudioHelper.build() to return our mockAudioHelper + // Need to use mockStatic for static methods + // Note: It's generally better to manage static mocks with try-with-resources or @ExtendWith if + // the runner supports it well, but for this structure, @Before/@After is common. + // AudioHelper static mock is managed with try-with-resources where it's used for instance + // creation. + mockStatic(AudioHelper::class.java).use { mockedAudioHelperStatic -> + mockedAudioHelperStatic + .`when` { AudioHelper.build() } + .thenReturn(mockAudioHelper) + liveSession = LiveSession(mockContext, mockSession, testDispatcher, null) + } + } + + @After + fun tearDown() { + mockedBuildVersion.close() + } + + @Test + fun `startAudioConversation on API M+ with permission proceeds normally`() = runTest { + // Arrange + mockedBuildVersion.`when` { Build.VERSION.SDK_INT }.thenReturn(Build.VERSION_CODES.M) + `when`(mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)) + .thenReturn(PackageManager.PERMISSION_GRANTED) + + // Act & Assert + // No exception should be thrown + liveSession.startAudioConversation() + } + + @Test + fun `startAudioConversation on API M+ without permission throws PermissionMissingException`() = + runTest { + // Arrange + mockedBuildVersion.`when` { Build.VERSION.SDK_INT }.thenReturn(Build.VERSION_CODES.M) + `when`(mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)) + .thenReturn(PackageManager.PERMISSION_DENIED) + + // Act & Assert + val exception = + assertThrows(PermissionMissingException::class.java) { + runTest { liveSession.startAudioConversation() } + } + assertEquals("Missing RECORD_AUDIO", exception.message) + } + + @Test + fun `startAudioConversation on API Pre-M with denied permission proceeds normally`() = runTest { + // Arrange + mockedBuildVersion.`when` { Build.VERSION.SDK_INT }.thenReturn(Build.VERSION_CODES.LOLLIPOP) + `when`(mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)) + .thenReturn(PackageManager.PERMISSION_DENIED) // This shouldn't be checked + + // Act & Assert + // No exception should be thrown + liveSession.startAudioConversation() + } + + @Test + fun `startAudioConversation on API Pre-M with granted permission proceeds normally`() = runTest { + // Arrange + mockedBuildVersion.`when` { Build.VERSION.SDK_INT }.thenReturn(Build.VERSION_CODES.LOLLIPOP) + `when`(mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)) + .thenReturn(PackageManager.PERMISSION_GRANTED) // This shouldn't be checked + + // Act & Assert + // No exception should be thrown + liveSession.startAudioConversation() + } +}