Skip to content

feat: Add runtime permission check for RECORD_AUDIO #7213

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 7 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
1 change: 1 addition & 0 deletions firebase-ai/firebase-ai.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PermissionMissingException> { session.startAudioConversation() }
}
}
Loading