Skip to content

Add RECORD_AUDIO permission check for Bidi Live API #7026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -69,6 +71,7 @@ internal constructor(
appCheckTokenProvider: InteropAppCheckTokenProvider? = null,
internalAuthProvider: InternalAuthProvider? = null,
) : this(
firebaseApp.applicationContext,
modelName,
blockingDispatcher,
config,
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Build.VERSION>
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> { 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()
}
}
Loading