Skip to content

Commit a9228d6

Browse files
Add RECORD_AUDIO permission check for Bidi Live API
Added a check for the RECORD_AUDIO permission in the AI Logic SDK for Bidi (Live API) within the `LiveSession.startAudioConversation()` method. If the permission is missing, a `PermissionMissingException` is thrown with the message "Missing RECORD_AUDIO". Also added unit tests to verify the new permission check behavior.
1 parent 6d166e0 commit a9228d6

File tree

4 files changed

+99
-2
lines changed

4 files changed

+99
-2
lines changed

firebase-ai/src/main/kotlin/com/google/firebase/ai/LiveGenerativeModel.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.firebase.ai
1818

19+
import android.content.Context
1920
import com.google.firebase.FirebaseApp
2021
import com.google.firebase.ai.common.APIController
2122
import com.google.firebase.ai.common.AppCheckHeaderProvider
@@ -48,6 +49,7 @@ import kotlinx.serialization.json.JsonObject
4849
@PublicPreviewAPI
4950
public class LiveGenerativeModel
5051
internal constructor(
52+
private val context: Context,
5153
private val modelName: String,
5254
@Blocking private val blockingDispatcher: CoroutineContext,
5355
private val config: LiveGenerationConfig? = null,
@@ -69,6 +71,7 @@ internal constructor(
6971
appCheckTokenProvider: InteropAppCheckTokenProvider? = null,
7072
internalAuthProvider: InternalAuthProvider? = null,
7173
) : this(
74+
firebaseApp.applicationContext,
7275
modelName,
7376
blockingDispatcher,
7477
config,
@@ -110,7 +113,11 @@ internal constructor(
110113
val receivedJson = JSON.parseToJsonElement(receivedJsonStr)
111114

112115
return if (receivedJson is JsonObject && "setupComplete" in receivedJson) {
113-
LiveSession(session = webSession, blockingDispatcher = blockingDispatcher)
116+
LiveSession(
117+
context = context,
118+
session = webSession,
119+
blockingDispatcher = blockingDispatcher
120+
)
114121
} else {
115122
webSession.close()
116123
throw ServiceConnectionHandshakeFailedException("Unable to connect to the server")

firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Exceptions.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ internal class UnknownException(message: String, cause: Throwable? = null) :
134134
internal class ContentBlockedException(message: String, cause: Throwable? = null) :
135135
FirebaseCommonAIException(message, cause)
136136

137+
/** The request is missing a permission that is required to perform the requested operation. */
138+
internal class PermissionMissingException(message: String, cause: Throwable? = null) :
139+
FirebaseCommonAIException(message, cause)
140+
137141
internal fun makeMissingCaseException(
138142
source: String,
139143
ordinal: Int

firebase-ai/src/main/kotlin/com/google/firebase/ai/type/LiveSession.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616

1717
package com.google.firebase.ai.type
1818

19-
import android.Manifest.permission.RECORD_AUDIO
19+
import android.Manifest
20+
import android.content.Context
21+
import android.content.pm.PackageManager
2022
import android.media.AudioFormat
2123
import android.media.AudioTrack
2224
import android.util.Log
2325
import androidx.annotation.RequiresPermission
2426
import com.google.firebase.ai.common.JSON
27+
import com.google.firebase.ai.common.PermissionMissingException
2528
import com.google.firebase.ai.common.util.CancelledCoroutineScope
2629
import com.google.firebase.ai.common.util.accumulateUntil
2730
import com.google.firebase.ai.common.util.childJob
@@ -56,6 +59,7 @@ import kotlinx.serialization.json.Json
5659
@OptIn(ExperimentalSerializationApi::class)
5760
public class LiveSession
5861
internal constructor(
62+
private val context: Context,
5963
private val session: ClientWebSocketSession,
6064
@Blocking private val blockingDispatcher: CoroutineContext,
6165
private var audioHelper: AudioHelper? = null
@@ -93,6 +97,12 @@ internal constructor(
9397
public suspend fun startAudioConversation(
9498
functionCallHandler: ((FunctionCallPart) -> FunctionResponsePart)? = null
9599
) {
100+
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) !=
101+
PackageManager.PERMISSION_GRANTED
102+
) {
103+
throw PermissionMissingException("Missing RECORD_AUDIO")
104+
}
105+
96106
FirebaseAIException.catchAsync {
97107
if (scope.isActive) {
98108
Log.w(
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.google.firebase.ai.type
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.content.pm.PackageManager
6+
import com.google.firebase.ai.common.PermissionMissingException
7+
import io.ktor.client.plugins.websocket.ClientWebSocketSession
8+
import kotlin.coroutines.CoroutineContext
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
11+
import kotlinx.coroutines.test.runTest
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertThrows
14+
import org.junit.Before
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import org.mockito.Mock
18+
import org.mockito.Mockito.mockStatic
19+
import org.mockito.Mockito.`when`
20+
import org.mockito.junit.MockitoJUnitRunner
21+
22+
@OptIn(ExperimentalCoroutinesApi::class)
23+
@RunWith(MockitoJUnitRunner::class)
24+
class LiveSessionTest {
25+
26+
@Mock private lateinit var mockContext: Context
27+
@Mock private lateinit var mockPackageManager: PackageManager
28+
@Mock private lateinit var mockSession: ClientWebSocketSession
29+
@Mock private lateinit var mockAudioHelper: AudioHelper
30+
31+
private lateinit var testDispatcher: CoroutineContext
32+
private lateinit var liveSession: LiveSession
33+
34+
@Before
35+
fun setUp() {
36+
testDispatcher = UnconfinedTestDispatcher()
37+
`when`(mockContext.packageManager).thenReturn(mockPackageManager)
38+
39+
// Mock AudioHelper.build() to return our mockAudioHelper
40+
// Need to use mockStatic for static methods
41+
mockStatic(AudioHelper::class.java).use { mockedAudioHelper ->
42+
mockedAudioHelper.`when`<AudioHelper> { AudioHelper.build() }.thenReturn(mockAudioHelper)
43+
liveSession = LiveSession(mockContext, mockSession, testDispatcher, null)
44+
}
45+
}
46+
47+
@Test
48+
fun `startAudioConversation with RECORD_AUDIO permission proceeds normally`() = runTest {
49+
// Arrange
50+
`when`(
51+
mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
52+
)
53+
.thenReturn(PackageManager.PERMISSION_GRANTED)
54+
55+
// Act & Assert
56+
// No exception should be thrown
57+
liveSession.startAudioConversation()
58+
}
59+
60+
@Test
61+
fun `startAudioConversation without RECORD_AUDIO permission throws PermissionMissingException`() =
62+
runTest {
63+
// Arrange
64+
`when`(
65+
mockContext.checkSelfPermission(Manifest.permission.RECORD_AUDIO)
66+
)
67+
.thenReturn(PackageManager.PERMISSION_DENIED)
68+
69+
// Act & Assert
70+
val exception =
71+
assertThrows(PermissionMissingException::class.java) {
72+
runTest { liveSession.startAudioConversation() }
73+
}
74+
assertEquals("Missing RECORD_AUDIO", exception.message)
75+
}
76+
}

0 commit comments

Comments
 (0)