Skip to content
Open
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 @@ -4299,6 +4299,7 @@ class BrowserTabViewModel @Inject constructor(
method,
id,
data,
tabId = tabId,
)
withContext(dispatchers.main()) {
response?.let {
Expand Down
9 changes: 9 additions & 0 deletions duckchat/duckchat-impl/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<application>
<activity
android:name="com.duckduckgo.duckchat.impl.ui.settings.DuckChatSettingsActivity"
Expand All @@ -34,5 +38,10 @@
android:name=".subscription.DuckAiPaidSettingsActivity"
android:exported="false"
android:label="@string/duck_ai_paid_settings_title"/>

<service
android:name=".ui.DuckChatVoiceMicrophoneService"
android:exported="false"
android:foregroundServiceType="microphone" />
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ class RealDuckChatJSHelper @Inject constructor(
}

METHOD_VOICE_SESSION_STARTED -> {
voiceSessionStateManager.onVoiceSessionStarted()
voiceSessionStateManager.onVoiceSessionStarted(tabId)
duckChatPixels.reportVoiceSessionStarted()
null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
cursor[bot] marked this conversation as resolved.
}

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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.

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()
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

@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()
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

companion object {
private const val STANDALONE_SESSION_ID = "__duck_ai_standalone__"
}
}
5 changes: 5 additions & 0 deletions duckchat/duckchat-impl/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@
<string name="duckAiModelPickerAdvancedModels" translatable="false">Advanced Models</string>
<string name="duckAiModelPickerBasicModels" translatable="false">Basic Models</string>
<string name="duckAiModelPickerPremiumModels" translatable="false">Advanced Models \u2014 DuckDuckGo subscription</string>

<!-- Duck.ai voice microphone foreground service notification -->
<string name="duckAiVoiceNotificationTitle">Duck.ai Voice Chat Active</string>
<string name="duckAiVoiceNotificationMessage">Microphone is in use by Duck.ai</string>
<string name="duckAiVoiceNotificationChannelName">Duck.ai Voice Chat</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading