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" + } }