Skip to content
Merged
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
@@ -0,0 +1,29 @@
/*
* 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

object DuckChatConstants {
const val HOST_DUCK_AI = "duck.ai"

object StandaloneConstants {
const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile"
const val COUNT = "count"
const val INDEX = "index"
const val OK = "ok"
const val REASON = "reason"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams
import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI
import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
import com.duckduckgo.duckchat.impl.inputscreen.newaddressbaroption.NewAddressBarCallback
Expand Down Expand Up @@ -65,6 +66,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.logcat
import okhttp3.HttpUrl.Companion.toHttpUrl
import javax.inject.Inject

interface DuckChatInternal : DuckChat {
Expand Down Expand Up @@ -181,6 +183,11 @@ interface DuckChatInternal : DuckChat {
*/
fun isImageUploadEnabled(): Boolean

/**
* Returns whether standalone migration is supported.
*/
fun isStandaloneMigrationEnabled(): Boolean

/**
* Returns the time a Duck Chat session should be kept alive
*/
Expand All @@ -206,6 +213,12 @@ interface DuckChatInternal : DuckChat {
*/
fun isDuckChatFeatureEnabled(): Boolean

/**
* This method takes a [url] and returns `true` or `false`.
* @return `true` if the given [url] can be handled in the duck ai webview and `false` otherwise.
*/
fun canHandleOnAiWebView(url: String): Boolean

/**
* Indicates whether Input Screen will present the input box at the bottom, if user has the omnibar also set to the bottom position.
* Otherwise, the input box will be at the top.
Expand Down Expand Up @@ -315,6 +328,7 @@ class RealDuckChat @Inject constructor(
private var isAddressBarEntryPointEnabled: Boolean = false
private var isVoiceSearchEntryPointEnabled: Boolean = false
private var isImageUploadEnabled: Boolean = false
private var isStandaloneMigrationEnabled: Boolean = false
private var keepSessionAliveInMinutes: Int = DEFAULT_SESSION_ALIVE
private var clearChatHistory: Boolean = true
private var inputScreenMainButtonsEnabled = false
Expand Down Expand Up @@ -423,6 +437,10 @@ class RealDuckChat @Inject constructor(
}
}

override fun canHandleOnAiWebView(url: String): Boolean {
return runCatching { HOST_DUCK_AI == url.toHttpUrl().topPrivateDomain() || url == REVOKE_URL }.getOrElse { false }
}

override fun isAddressBarEntryPointEnabled(): Boolean = isAddressBarEntryPointEnabled
override fun isVoiceSearchEntryPointEnabled(): Boolean = isVoiceSearchEntryPointEnabled

Expand Down Expand Up @@ -462,6 +480,8 @@ class RealDuckChat @Inject constructor(

override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled

override fun isStandaloneMigrationEnabled(): Boolean = isStandaloneMigrationEnabled

override fun keepSessionIntervalInMinutes() = keepSessionAliveInMinutes

override fun openDuckChat() {
Expand Down Expand Up @@ -701,6 +721,7 @@ class RealDuckChat @Inject constructor(
isAddressBarEntryPointEnabled = settingsJson?.addressBarEntryPoint ?: false
isVoiceSearchEntryPointEnabled = duckChatFeature.duckAiVoiceSearch().isEnabled()
isImageUploadEnabled = imageUploadFeature.self().isEnabled()
isStandaloneMigrationEnabled = duckChatFeature.standaloneMigration().isEnabled()

keepSession.value = duckChatFeature.keepSession().isEnabled()
keepSessionAliveInMinutes = settingsJson?.sessionTimeoutMinutes ?: DEFAULT_SESSION_ALIVE
Expand Down Expand Up @@ -763,5 +784,6 @@ class RealDuckChat @Inject constructor(
private const val BANG_QUERY_NAME = "bang"
private const val BANG_QUERY_VALUE = "true"
private const val DEFAULT_SESSION_ALIVE = 60
private const val REVOKE_URL = "https://duckduckgo.com/revoke-duckai-access"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ interface DuckChatFeature {
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
fun duckAiVoiceSearch(): Toggle

/**
* @return `true` when standalone migration is supported
* If the remote feature is not present defaults to `false`
*/
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun standaloneMigration(): Toggle

/**
* @return `true` when the "Hide AI Generated Images" option should be visible in AI Features Settings.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ class RealDuckChatJSHelper @Inject constructor(
private val dataStore: DuckChatDataStore,
private val duckAiMetricCollector: DuckAiMetricCollector,
) : DuckChatJSHelper {

override suspend fun processJsCallbackMessage(
featureName: String,
method: String,
Expand Down Expand Up @@ -172,6 +171,7 @@ class RealDuckChatJSHelper @Inject constructor(
put(SUPPORTS_NATIVE_CHAT_INPUT, false)
put(SUPPORTS_CHAT_ID_RESTORATION, duckChat.isDuckChatFullScreenModeEnabled())
put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled())
put(SUPPORTS_STANDALONE_MIGRATION, duckChat.isStandaloneMigrationEnabled())
}
return JsCallbackData(jsonPayload, featureName, method, id)
}
Expand Down Expand Up @@ -234,6 +234,7 @@ class RealDuckChatJSHelper @Inject constructor(
private const val SUPPORTS_NATIVE_CHAT_INPUT = "supportsNativeChatInput"
private const val SUPPORTS_IMAGE_UPLOAD = "supportsImageUpload"
private const val SUPPORTS_CHAT_ID_RESTORATION = "supportsURLChatIDRestoration"
private const val SUPPORTS_STANDALONE_MIGRATION = "supportsStandaloneMigration"
private const val REPORT_METRIC = "reportMetric"
private const val PLATFORM = "platform"
private const val ANDROID = "android"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package com.duckduckgo.duckchat.impl.messaging
import com.duckduckgo.common.utils.AppUrl
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI
import com.duckduckgo.js.messaging.api.JsMessage
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessageHandler
Expand All @@ -41,6 +42,7 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ
override val allowedDomains: List<String> =
listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* 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.messaging

import com.duckduckgo.common.utils.AppUrl
import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI
import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.COUNT
import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.INDEX
import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.OK
import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.REASON
import com.duckduckgo.duckchat.impl.DuckChatConstants.StandaloneConstants.SERIALIZED_MIGRATION_FILE
import com.duckduckgo.js.messaging.api.JsCallbackData
import com.duckduckgo.js.messaging.api.JsMessage
import com.duckduckgo.js.messaging.api.JsMessageCallback
import com.duckduckgo.js.messaging.api.JsMessageHandler
import com.duckduckgo.js.messaging.api.JsMessaging
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import org.json.JSONObject
import javax.inject.Inject

interface StandaloneDuckChatStore {
fun storeMigrationItem(item: String)
fun getMigrationItemByIndex(index: Int): String?
fun getMigrationItemCount(): Int
fun clearMigrationItems()
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class InMemoryStandaloneStore @Inject constructor() : StandaloneDuckChatStore {
private val migrationItems = mutableListOf<String>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still have a concern that this could cause issues but we can deactivate the feature if we see an uptick in OOMs


override fun storeMigrationItem(item: String) {
migrationItems.add(item)
}

override fun getMigrationItemByIndex(index: Int): String? {
return migrationItems.getOrNull(index)
}

override fun getMigrationItemCount(): Int = migrationItems.size

override fun clearMigrationItems() {
migrationItems.clear()
}
}

@ContributesMultibinding(AppScope::class)
class StoreMigrationDataHandler @Inject constructor(
private val standaloneDuckChatStore: StandaloneDuckChatStore,
) : ContentScopeJsMessageHandlersPlugin {
override fun getJsMessageHandler(): JsMessageHandler =
object : JsMessageHandler {
override fun process(
jsMessage: JsMessage,
jsMessaging: JsMessaging,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessage.id.isNullOrEmpty()) return
val item = jsMessage.params.optString(SERIALIZED_MIGRATION_FILE)

val result = if (!item.isNullOrEmpty() && item != JSONObject.NULL.toString()) {
standaloneDuckChatStore.storeMigrationItem(item)
true
} else {
false
}

val payload = JSONObject().apply {
put(OK, result)
if (!result) put(REASON, "Missing or invalid serializedMigrationFile")
}

jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!))
}

override val allowedDomains: List<String> =
listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
override val methods: List<String> = listOf("storeMigrationData")
}
}

@ContributesMultibinding(AppScope::class)
class GetMigrationInfoHandler @Inject constructor(
private val standaloneDuckChatStore: StandaloneDuckChatStore,
) : ContentScopeJsMessageHandlersPlugin {
override fun getJsMessageHandler(): JsMessageHandler =
object : JsMessageHandler {
override fun process(
jsMessage: JsMessage,
jsMessaging: JsMessaging,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessage.id.isNullOrEmpty()) return

val count = standaloneDuckChatStore.getMigrationItemCount()
val payload = JSONObject().apply {
put(OK, true)
put(COUNT, count)
}

jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!))
}

override val allowedDomains: List<String> =
listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
override val methods: List<String> = listOf("getMigrationInfo")
}
}

@ContributesMultibinding(AppScope::class)
class GetMigrationDataByIndexHandler @Inject constructor(
private val standaloneDuckChatStore: StandaloneDuckChatStore,
) : ContentScopeJsMessageHandlersPlugin {
override fun getJsMessageHandler(): JsMessageHandler =
object : JsMessageHandler {
override fun process(
jsMessage: JsMessage,
jsMessaging: JsMessaging,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessage.id.isNullOrEmpty()) return
val index = jsMessage.params.optInt(INDEX, -1)
val value = standaloneDuckChatStore.getMigrationItemByIndex(index)

val payload = JSONObject().apply {
if (value == null) {
put(OK, false)
put(REASON, "nothing at index: $index")
} else {
put(OK, true)
put(SERIALIZED_MIGRATION_FILE, value)
}
}
jsMessaging.onResponse(JsCallbackData(payload, featureName, jsMessage.method, jsMessage.id!!))
}

override val allowedDomains: List<String> =
listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
override val methods: List<String> = listOf("getMigrationDataByIndex")
}
}

@ContributesMultibinding(AppScope::class)
class ClearMigrationDataHandler @Inject constructor(
private val standaloneDuckChatStore: StandaloneDuckChatStore,
) : ContentScopeJsMessageHandlersPlugin {
override fun getJsMessageHandler(): JsMessageHandler =
object : JsMessageHandler {
override fun process(
jsMessage: JsMessage,
jsMessaging: JsMessaging,
jsMessageCallback: JsMessageCallback?,
) {
if (jsMessage.id.isNullOrEmpty()) return
standaloneDuckChatStore.clearMigrationItems()
val jsonPayload = JSONObject().apply { put(OK, true) }
jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!))
}

override val allowedDomains: List<String> =
listOf(
AppUrl.Url.HOST,
HOST_DUCK_AI,
)

override val featureName: String = "aiChat"
override val methods: List<String> = listOf("clearMigrationData")
}
}
Loading
Loading