diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
index 9855a75d9d8c..0cbcc7c71fce 100644
--- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
@@ -4299,6 +4299,7 @@ class BrowserTabViewModel @Inject constructor(
method,
id,
data,
+ tabId = tabId,
)
withContext(dispatchers.main()) {
response?.let {
diff --git a/duckchat/duckchat-impl/src/main/AndroidManifest.xml b/duckchat/duckchat-impl/src/main/AndroidManifest.xml
index 7ce938a30661..ba8d94a92cb7 100644
--- a/duckchat/duckchat-impl/src/main/AndroidManifest.xml
+++ b/duckchat/duckchat-impl/src/main/AndroidManifest.xml
@@ -16,6 +16,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
index eaee35bba7ac..44ab9051d1ff 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt
@@ -225,7 +225,7 @@ class RealDuckChatJSHelper @Inject constructor(
}
METHOD_VOICE_SESSION_STARTED -> {
- voiceSessionStateManager.onVoiceSessionStarted()
+ voiceSessionStateManager.onVoiceSessionStarted(tabId)
duckChatPixels.reportVoiceSessionStarted()
null
}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatVoiceMicrophoneService.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatVoiceMicrophoneService.kt
new file mode 100644
index 000000000000..000e6eb5b6ad
--- /dev/null
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatVoiceMicrophoneService.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025 DuckDuckGo
+ *
+ * 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.duckduckgo.duckchat.impl.ui
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import com.duckduckgo.anvil.annotations.InjectWith
+import com.duckduckgo.appbuildconfig.api.AppBuildConfig
+import com.duckduckgo.di.scopes.ServiceScope
+import com.duckduckgo.duckchat.impl.R
+import dagger.android.AndroidInjection
+import javax.inject.Inject
+
+/**
+ * Foreground service that keeps the process alive and signals to Android that microphone access
+ * is intentionally used while Duck.ai voice mode is active in the background.
+ */
+@InjectWith(scope = ServiceScope::class)
+class DuckChatVoiceMicrophoneService : Service() {
+
+ @Inject
+ lateinit var appBuildConfig: AppBuildConfig
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ AndroidInjection.inject(this)
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ buildNotification(),
+ if (appBuildConfig.sdkInt >= 30) {
+ FOREGROUND_SERVICE_TYPE_MICROPHONE
+ } else {
+ 0
+ },
+ )
+ return START_NOT_STICKY
+ }
+
+ private fun buildNotification(): Notification {
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ val pendingIntent = launchIntent?.let {
+ PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE)
+ }
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.duckAiVoiceNotificationTitle))
+ .setContentText(getString(R.string.duckAiVoiceNotificationMessage))
+ .setSmallIcon(com.duckduckgo.mobile.android.R.drawable.notification_logo)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setContentIntent(pendingIntent)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.duckAiVoiceNotificationChannelName),
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
+ }
+
+ companion object {
+ private const val NOTIFICATION_ID = 9100
+ private const val CHANNEL_ID = "duck_ai_voice_microphone"
+
+ fun start(context: Context) {
+ ContextCompat.startForegroundService(context, Intent(context, DuckChatVoiceMicrophoneService::class.java))
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, DuckChatVoiceMicrophoneService::class.java))
+ }
+ }
+}
diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/voice/VoiceSessionStateManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/voice/VoiceSessionStateManager.kt
index 476088363010..5d352e6e61bc 100644
--- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/voice/VoiceSessionStateManager.kt
+++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/voice/VoiceSessionStateManager.kt
@@ -16,40 +16,84 @@
package com.duckduckgo.duckchat.impl.voice
+import android.content.Context
+import com.duckduckgo.app.di.AppCoroutineScope
+import com.duckduckgo.app.tabs.model.TabRepository
import com.duckduckgo.browser.api.BrowserLifecycleObserver
+import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.di.scopes.AppScope
+import com.duckduckgo.duckchat.impl.ui.DuckChatVoiceMicrophoneService
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
import javax.inject.Inject
interface VoiceSessionStateManager {
val isVoiceSessionActive: Boolean
- fun onVoiceSessionStarted()
+ get() = false
+ fun onVoiceSessionStarted(tabId: String)
fun onVoiceSessionEnded()
}
@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class, boundType = VoiceSessionStateManager::class)
@ContributesMultibinding(AppScope::class, boundType = BrowserLifecycleObserver::class)
-class RealVoiceSessionStateManager @Inject constructor() : VoiceSessionStateManager, BrowserLifecycleObserver {
+class RealVoiceSessionStateManager @Inject constructor(
+ private val context: Context,
+ private val tabRepository: TabRepository,
+ @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
+) : VoiceSessionStateManager, BrowserLifecycleObserver {
+ private val listenJob = ConflatedJob()
+
+ // null = no session, STANDALONE_SESSION_ID = session without a browser tab, non-empty = tab session
@Volatile
- // NOTE: We are unable to detect that voice chat has ended IF the user closes the tab running voice chat.
- override var isVoiceSessionActive: Boolean = false
- private set
+ private var activeSessionTabId: String? = null
+
+ override val isVoiceSessionActive: Boolean
+ get() = activeSessionTabId != null
- override fun onVoiceSessionStarted() {
- isVoiceSessionActive = true
+ @Synchronized
+ override fun onVoiceSessionStarted(tabId: String) {
+ activeSessionTabId = tabId.ifBlank { STANDALONE_SESSION_ID }
+ DuckChatVoiceMicrophoneService.start(context)
+ if (tabId.isNotBlank()) {
+ listenToTabRemoval()
+ }
}
+ @Synchronized
override fun onVoiceSessionEnded() {
- isVoiceSessionActive = false
+ listenJob.cancel()
+ activeSessionTabId = null
+ DuckChatVoiceMicrophoneService.stop(context)
}
override fun onOpen(isFreshLaunch: Boolean) {
if (isFreshLaunch) {
- isVoiceSessionActive = false
+ onVoiceSessionEnded()
+ }
+ }
+
+ override fun onExit() {
+ onVoiceSessionEnded()
+ }
+
+ private fun listenToTabRemoval() {
+ listenJob += appCoroutineScope.launch {
+ tabRepository.flowTabs.drop(1).collect { tabs ->
+ val tabId = activeSessionTabId ?: return@collect
+ if (tabs.none { it.tabId == tabId }) {
+ onVoiceSessionEnded()
+ }
+ }
}
}
+
+ companion object {
+ private const val STANDALONE_SESSION_ID = "__duck_ai_standalone__"
+ }
}
diff --git a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
index edc04531dd2e..a4a1cdbbd3ad 100644
--- a/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
+++ b/duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
@@ -25,4 +25,9 @@
Advanced Models
Basic Models
Advanced Models \u2014 DuckDuckGo subscription
+
+
+ Duck.ai Voice Chat Active
+ Microphone is in use by Duck.ai
+ Duck.ai Voice Chat
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
index 1caa166708be..15ea66945630 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt
@@ -1611,17 +1611,19 @@ class RealDuckChatJSHelperTest {
@Test
fun whenVoiceSessionStartedThenPixelFiredAndStateUpdated() = runTest {
+ val tabId = "test-tab-id"
val result = testee.processJsCallbackMessage(
"aiChat",
"voiceSessionStarted",
null,
null,
pageContext = viewModel.updatedPageContext,
+ tabId = tabId,
)
assertNull(result)
verify(mockDuckChatPixels).reportVoiceSessionStarted()
- verify(mockVoiceSessionStateManager).onVoiceSessionStarted()
+ verify(mockVoiceSessionStateManager).onVoiceSessionStarted(tabId)
}
@Test
diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/voice/RealVoiceSessionStateManagerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/voice/RealVoiceSessionStateManagerTest.kt
index ce1ce74a07b7..6f9dcff4e373 100644
--- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/voice/RealVoiceSessionStateManagerTest.kt
+++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/voice/RealVoiceSessionStateManagerTest.kt
@@ -16,18 +16,53 @@
package com.duckduckgo.duckchat.impl.voice
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.duckduckgo.app.tabs.model.TabEntity
+import com.duckduckgo.app.tabs.model.TabRepository
+import com.duckduckgo.common.test.CoroutineTestRule
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalCoroutinesApi::class)
class RealVoiceSessionStateManagerTest {
+ @get:Rule
+ val coroutineTestRule = CoroutineTestRule()
+
+ private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val tabRepository: TabRepository = mock()
+ private val tabsFlow = MutableStateFlow>(emptyList())
+
private lateinit var testee: RealVoiceSessionStateManager
+ @After
+ fun teardown() {
+ // Cancels the flowTabs collect coroutine so runTest doesn't fail with UncompletedCoroutinesError
+ testee.onVoiceSessionEnded()
+ }
+
@Before
fun setup() {
- testee = RealVoiceSessionStateManager()
+ whenever(tabRepository.flowTabs).thenReturn(tabsFlow)
+ testee = RealVoiceSessionStateManager(
+ context = context,
+ tabRepository = tabRepository,
+ appCoroutineScope = coroutineTestRule.testScope,
+ )
}
@Test
@@ -37,14 +72,14 @@ class RealVoiceSessionStateManagerTest {
@Test
fun whenVoiceSessionStartedThenIsVoiceSessionActiveIsTrue() {
- testee.onVoiceSessionStarted()
+ testee.onVoiceSessionStarted(TAB_ID)
assertTrue(testee.isVoiceSessionActive)
}
@Test
fun whenVoiceSessionEndedThenIsVoiceSessionActiveIsFalse() {
- testee.onVoiceSessionStarted()
+ testee.onVoiceSessionStarted(TAB_ID)
testee.onVoiceSessionEnded()
assertFalse(testee.isVoiceSessionActive)
@@ -59,15 +94,15 @@ class RealVoiceSessionStateManagerTest {
@Test
fun whenVoiceSessionStartedMultipleTimesThenIsVoiceSessionActiveIsTrue() {
- testee.onVoiceSessionStarted()
- testee.onVoiceSessionStarted()
+ testee.onVoiceSessionStarted(TAB_ID)
+ testee.onVoiceSessionStarted(TAB_ID)
assertTrue(testee.isVoiceSessionActive)
}
@Test
fun whenFreshLaunchAndVoiceSessionWasActiveThenIsVoiceSessionActiveIsFalse() {
- testee.onVoiceSessionStarted()
+ testee.onVoiceSessionStarted(TAB_ID)
testee.onOpen(isFreshLaunch = true)
assertFalse(testee.isVoiceSessionActive)
@@ -75,9 +110,98 @@ class RealVoiceSessionStateManagerTest {
@Test
fun whenNotFreshLaunchAndVoiceSessionWasActiveThenIsVoiceSessionActiveRemainsTrue() {
- testee.onVoiceSessionStarted()
+ testee.onVoiceSessionStarted(TAB_ID)
testee.onOpen(isFreshLaunch = false)
assertTrue(testee.isVoiceSessionActive)
}
+
+ @Test
+ fun whenFreshLaunchAndNoActiveSessionThenRemainsInactive() {
+ testee.onOpen(isFreshLaunch = true)
+
+ assertFalse(testee.isVoiceSessionActive)
+ }
+
+ @Test
+ fun whenExitAndVoiceSessionWasActiveThenIsVoiceSessionActiveIsFalse() {
+ testee.onVoiceSessionStarted(TAB_ID)
+ testee.onExit()
+
+ assertFalse(testee.isVoiceSessionActive)
+ }
+
+ @Test
+ fun whenExitAndNoActiveSessionThenRemainsInactive() {
+ testee.onExit()
+
+ assertFalse(testee.isVoiceSessionActive)
+ }
+
+ @Test
+ fun whenVoiceSessionStartedWithBlankTabIdThenSessionIsActiveButTabRemovalNotTracked() = coroutineTestRule.testScope.runTest {
+ tabsFlow.value = listOf(tabEntity(TAB_ID))
+ testee.onVoiceSessionStarted("")
+
+ tabsFlow.value = emptyList()
+ advanceUntilIdle()
+
+ // Session stays active — no tab to track, so tab removal has no effect
+ assertTrue(testee.isVoiceSessionActive)
+ testee.onVoiceSessionEnded()
+ }
+
+ @Test
+ fun whenActiveTabIsClosedThenSessionEnded() = coroutineTestRule.testScope.runTest {
+ tabsFlow.value = listOf(tabEntity(TAB_ID))
+ testee.onVoiceSessionStarted(TAB_ID)
+
+ tabsFlow.value = emptyList()
+ advanceUntilIdle()
+
+ assertFalse(testee.isVoiceSessionActive)
+ }
+
+ @Test
+ fun whenDifferentTabIsClosedThenSessionRemainsActive() = coroutineTestRule.testScope.runTest {
+ tabsFlow.value = listOf(tabEntity(TAB_ID), tabEntity(OTHER_TAB_ID))
+ testee.onVoiceSessionStarted(TAB_ID)
+
+ tabsFlow.value = listOf(tabEntity(TAB_ID))
+ advanceUntilIdle()
+
+ assertTrue(testee.isVoiceSessionActive)
+ testee.onVoiceSessionEnded() // cancel collect coroutine before runTest checks for leaks
+ }
+
+ @Test
+ fun whenNewTabAddedThenSessionRemainsActive() = coroutineTestRule.testScope.runTest {
+ tabsFlow.value = listOf(tabEntity(TAB_ID))
+ testee.onVoiceSessionStarted(TAB_ID)
+
+ tabsFlow.value = listOf(tabEntity(TAB_ID), tabEntity(OTHER_TAB_ID))
+ advanceUntilIdle()
+
+ assertTrue(testee.isVoiceSessionActive)
+ testee.onVoiceSessionEnded() // cancel collect coroutine before runTest checks for leaks
+ }
+
+ @Test
+ fun whenSessionEndedManuallyAndActiveTabSubsequentlyRemovedThenNoAdditionalEffect() = coroutineTestRule.testScope.runTest {
+ tabsFlow.value = listOf(tabEntity(TAB_ID))
+ testee.onVoiceSessionStarted(TAB_ID)
+ testee.onVoiceSessionEnded()
+
+ tabsFlow.value = emptyList()
+ advanceUntilIdle()
+
+ assertFalse(testee.isVoiceSessionActive)
+ }
+
+ private fun tabEntity(tabId: String) = TabEntity(tabId = tabId)
+
+ companion object {
+ private const val TAB_ID = "test-tab-id"
+ private const val OTHER_TAB_ID = "other-tab-id"
+ }
}