diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 25bed4091dbe..f7d95274b7a3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -38,6 +38,10 @@ class DuckDuckGoUrlDetectorImpl @Inject constructor() : DuckDuckGoUrlDetector { return runCatching { AppUrl.Url.HOST == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } } + override fun isDuckAiUrl(url: String): Boolean { + return runCatching { AppUrl.Url.HOST_DUCKAI == url.toHttpUrl().topPrivateDomain() }.getOrElse { false } + } + override fun isDuckDuckGoQueryUrl(uri: String): Boolean { return isDuckDuckGoUrl(uri) && hasQuery(uri) } diff --git a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt index 7a29c676c25e..15e284995310 100644 --- a/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt +++ b/browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt @@ -33,6 +33,12 @@ interface DuckDuckGoUrlDetector { */ fun isDuckDuckGoUrl(url: String): Boolean + /** + * This method takes a [url] and returns `true` or `false`. + * @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) and `false` otherwise. + */ + fun isDuckAiUrl(url: String): Boolean + /** * This method takes a [uri] and returns `true` or `false`. * @return `true` if the given [uri] is a DuckDuckGo query and `false` diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt index a59393e45bda..28012d4bc1a3 100644 --- a/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt @@ -20,6 +20,7 @@ class AppUrl { object Url { const val HOST = "duckduckgo.com" + const val HOST_DUCKAI = "duck.ai" const val API = "https://$HOST" const val HOME = "https://$HOST" const val COOKIES = "https://$HOST" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index 79c88dffd1f8..911ec60c651c 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -181,6 +181,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 */ @@ -315,6 +320,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 @@ -462,6 +468,8 @@ class RealDuckChat @Inject constructor( override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled + override fun isStandaloneMigrationEnabled(): Boolean = isStandaloneMigrationEnabled + override fun keepSessionIntervalInMinutes() = keepSessionAliveInMinutes override fun openDuckChat() { @@ -694,6 +702,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 diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 4527f272cfbb..f8e955278b9b 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -116,4 +116,11 @@ interface DuckChatFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) 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 } 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 232637aacdfc..6ae41053600a 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 @@ -16,6 +16,7 @@ package com.duckduckgo.duckchat.impl.helper +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.ChatState import com.duckduckgo.duckchat.impl.ChatState.HIDE @@ -28,6 +29,7 @@ import com.duckduckgo.duckchat.impl.store.DuckChatDataStore import com.duckduckgo.js.messaging.api.JsCallbackData import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.json.JSONObject import java.util.regex.Pattern import javax.inject.Inject @@ -47,7 +49,9 @@ class RealDuckChatJSHelper @Inject constructor( private val duckChatPixels: DuckChatPixels, private val dataStore: DuckChatDataStore, private val duckAiMetricCollector: DuckAiMetricCollector, + private val dispatchers: DispatcherProvider, ) : DuckChatJSHelper { + private val migrationItems = mutableListOf() override suspend fun processJsCallbackMessage( featureName: String, method: String, @@ -117,6 +121,22 @@ class RealDuckChatJSHelper @Inject constructor( null } + METHOD_STORE_MIGRATION_DATA -> id?.let { + getStoreMigrationDataResponse(featureName, method, it, data) + } + + METHOD_GET_MIGRATION_INFO -> id?.let { + getMigrationInfoResponse(featureName, method, it) + } + + METHOD_GET_MIGRATION_DATA_BY_INDEX -> id?.let { + getMigrationDataByIndexResponse(featureName, method, it, data) + } + + METHOD_CLEAR_MIGRATION_DATA -> id?.let { + getClearMigrationDataResponse(featureName, method, it) + } + else -> null } @@ -148,6 +168,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) } @@ -191,6 +212,90 @@ class RealDuckChatJSHelper @Inject constructor( } } + /** + * Accept incoming JSON payload { "serializedMigrationFile": "..." } + * Store the string value in an ordered list for later retrieval + */ + private suspend fun getStoreMigrationDataResponse( + featureName: String, + method: String, + id: String, + data: JSONObject?, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val item = data?.optString(SERIALIZED_MIGRATION_FILE) + val jsonPayload = JSONObject() + if (item != null && item != JSONObject.NULL) { + migrationItems.add(item) + jsonPayload.put(OK, true) + } else { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile") + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Return the count of strings previously stored. + * It's ok to return 0 if no items have been stored + */ + private suspend fun getMigrationInfoResponse( + featureName: String, + method: String, + id: String, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val count = migrationItems.size + val jsonPayload = JSONObject().apply { + put(OK, true) + put(COUNT, count) + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Try to lookup a string by index + * - when found, return { ok: true, serializedMigrationFile: '...' } + * - when missing, return { ok: false, reason: '...' } + */ + private suspend fun getMigrationDataByIndexResponse( + featureName: String, + method: String, + id: String, + data: JSONObject?, + ): JsCallbackData { + return withContext(dispatchers.io()) { + val index = data?.optInt(INDEX, -1) ?: -1 + val value = migrationItems.getOrNull(index) + val jsonPayload = JSONObject() + if (value == null) { + jsonPayload.put(OK, false) + jsonPayload.put(REASON, "nothing at index: $index") + } else { + jsonPayload.put(OK, true) + jsonPayload.put(SERIALIZED_MIGRATION_FILE, value) + } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + + /** + * Clear migration data, returning { ok: true } when complete + */ + private suspend fun getClearMigrationDataResponse( + featureName: String, + method: String, + id: String, + ): JsCallbackData { + return withContext(dispatchers.io()) { + migrationItems.clear() + val jsonPayload = JSONObject().apply { put(OK, true) } + JsCallbackData(jsonPayload, featureName, method, id) + } + } + companion object { const val DUCK_CHAT_FEATURE_NAME = "aiChat" private const val METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA = "getAIChatNativeHandoffData" @@ -210,6 +315,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" @@ -217,5 +323,17 @@ class RealDuckChatJSHelper @Inject constructor( private const val DEFAULT_SELECTOR = "'user-prompt'" private const val SUCCESS = "success" private const val ERROR = "error" + private const val OK = "ok" + private const val REASON = "reason" + + // Migration messaging constants + private const val METHOD_STORE_MIGRATION_DATA = "storeMigrationData" + private const val METHOD_GET_MIGRATION_INFO = "getMigrationInfo" + private const val METHOD_GET_MIGRATION_DATA_BY_INDEX = "getMigrationDataByIndex" + private const val METHOD_CLEAR_MIGRATION_DATA = "clearMigrationData" + + private const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile" + private const val COUNT = "count" + private const val INDEX = "index" } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt index c5181203ccde..2943824bd3ef 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt @@ -41,6 +41,7 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ override val allowedDomains: List = listOf( AppUrl.Url.HOST, + AppUrl.Url.HOST_DUCKAI, ) override val featureName: String = "aiChat" @@ -56,6 +57,12 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ "showChatInput", "reportMetric", "openKeyboard", + + // migration handlers + "storeMigrationData", + "getMigrationInfo", + "getMigrationDataByIndex", + "clearMigrationData", ) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt index 31ea8d298b28..d2280eecea35 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt @@ -47,6 +47,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.tabs.BrowserNav import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -130,6 +131,9 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c @Inject lateinit var duckChatJSHelper: DuckChatJSHelper + @Inject + lateinit var duckDuckGoUrlDetector: DuckDuckGoUrlDetector + @Inject lateinit var subscriptionsHandler: SubscriptionsHandler @@ -209,7 +213,12 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c view?.requestFocusNodeHref(resultMsg) val newWindowUrl = resultMsg?.data?.getString("url") if (newWindowUrl != null) { - startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) + if (duckDuckGoUrlDetector.isDuckAiUrl(newWindowUrl)) { + // Allow Duck.ai links to load within the same WebView (in-sheet navigation) + simpleWebview.loadUrl(newWindowUrl) + } else { + startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl)) + } return true } return false 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 a77cf0d59a51..44acae7c8e4e 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 @@ -58,6 +58,7 @@ class RealDuckChatJSHelperTest { dataStore = mockDataStore, duckChatPixels = mockDuckChatPixels, duckAiMetricCollector = mockDuckAiMetricCollector, + dispatchers = coroutineRule.testDispatcherProvider, ) @Test @@ -185,6 +186,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -214,6 +216,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -243,6 +246,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", true) put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -405,6 +409,7 @@ class RealDuckChatJSHelperTest { put("supportsNativeChatInput", false) put("supportsURLChatIDRestoration", false) put("supportsImageUpload", true) + put("supportsStandaloneMigration", false) } assertEquals(expectedPayload.toString(), result!!.params.toString()) @@ -512,4 +517,90 @@ class RealDuckChatJSHelperTest { assertEquals(expectedPayload.toString(), result!!.params.toString()) } + + @Test + fun whenStoreMigrationDataThenItemIsStoredAndInfoCountReflectsIt() = runTest { + val featureName = "aiChat" + val id = "1" + + // store two items + val item1 = JSONObject(mapOf("serializedMigrationFile" to "file-1")) + val item2 = JSONObject(mapOf("serializedMigrationFile" to "file-2")) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item1) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, item2) + + // get count + val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) + val expected = JSONObject().apply { + put("ok", true) + put("count", 2) + } + assertEquals(expected.toString(), info!!.params.toString()) + } + + @Test + fun whenGetMigrationDataByIndexWithValidIndexThenReturnItem() = runTest { + val featureName = "aiChat" + val id = "1" + + // store items + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) + + val result = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 1))) + val expected = JSONObject().apply { + put("ok", true) + put("serializedMigrationFile", "file-2") + } + assertEquals(expected.toString(), result!!.params.toString()) + } + + @Test + fun whenGetMigrationDataByIndexWithInvalidIndexThenReturnEmptyPayload() = runTest { + val featureName = "aiChat" + val id = "1" + + // store one item + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + + // negative index + val negative = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to -1))) + val expectedNegative = JSONObject().apply { + put("ok", false) + put("reason", "nothing at index: -1") + } + assertEquals(expectedNegative.toString(), negative!!.params.toString()) + + // out of range index + val outOfRange = testee.processJsCallbackMessage(featureName, "getMigrationDataByIndex", id, JSONObject(mapOf("index" to 5))) + val expectedOutOfRange = JSONObject().apply { + put("ok", false) + put("reason", "nothing at index: 5") + } + assertEquals(expectedOutOfRange.toString(), outOfRange!!.params.toString()) + } + + @Test + fun whenClearMigrationDataThenItemsRemovedAndCountZero() = runTest { + val featureName = "aiChat" + val id = "1" + + // store items + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-1"))) + testee.processJsCallbackMessage(featureName, "storeMigrationData", id, JSONObject(mapOf("serializedMigrationFile" to "file-2"))) + + // clear + val clearResult = testee.processJsCallbackMessage(featureName, "clearMigrationData", id, null) + // clear returns ok true + val expectedClear = JSONObject().apply { put("ok", true) } + assertEquals(expectedClear.toString(), clearResult!!.params.toString()) + + // count is zero + val info = testee.processJsCallbackMessage(featureName, "getMigrationInfo", id, null) + val expected = JSONObject().apply { + put("ok", true) + put("count", 0) + } + assertEquals(expected.toString(), info!!.params.toString()) + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt index 97d3ddce4df4..ba07ccd56abf 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandlerTest.kt @@ -28,8 +28,9 @@ class DuckChatContentScopeJsMessageHandlerTest { @Test fun `only allow duckduckgo dot com domains`() { val domains = handler.allowedDomains - assertTrue(domains.size == 1) - assertTrue(domains.first() == "duckduckgo.com") + assertTrue(domains.size == 2) + assertTrue(domains[0] == "duckduckgo.com") + assertTrue(domains[1] == "duck.ai") } @Test @@ -40,7 +41,7 @@ class DuckChatContentScopeJsMessageHandlerTest { @Test fun `only contains valid methods`() { val methods = handler.methods - assertTrue(methods.size == 10) + assertTrue(methods.size == 14) assertTrue(methods[0] == "getAIChatNativeHandoffData") assertTrue(methods[1] == "getAIChatNativeConfigValues") assertTrue(methods[2] == "openAIChat") @@ -51,6 +52,10 @@ class DuckChatContentScopeJsMessageHandlerTest { assertTrue(methods[7] == "showChatInput") assertTrue(methods[8] == "reportMetric") assertTrue(methods[9] == "openKeyboard") + assertTrue(methods[10] == "storeMigrationData") + assertTrue(methods[11] == "getMigrationInfo") + assertTrue(methods[12] == "getMigrationDataByIndex") + assertTrue(methods[13] == "clearMigrationData") } private val callback = object : JsMessageCallback() {