Skip to content

Commit 58a968e

Browse files
committed
Duck.ai - support standalone migration
1 parent 678c492 commit 58a968e

File tree

10 files changed

+261
-4
lines changed

10 files changed

+261
-4
lines changed

app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ class DuckDuckGoUrlDetectorImpl @Inject constructor() : DuckDuckGoUrlDetector {
3838
return runCatching { AppUrl.Url.HOST == url.toHttpUrl().topPrivateDomain() }.getOrElse { false }
3939
}
4040

41+
override fun isDuckAiUrl(url: String): Boolean {
42+
return runCatching { AppUrl.Url.HOST_DUCKAI == url.toHttpUrl().topPrivateDomain() }.getOrElse { false }
43+
}
44+
4145
override fun isDuckDuckGoQueryUrl(uri: String): Boolean {
4246
return isDuckDuckGoUrl(uri) && hasQuery(uri)
4347
}

browser-api/src/main/java/com/duckduckgo/app/browser/DuckDuckGoUrlDetector.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ interface DuckDuckGoUrlDetector {
3333
*/
3434
fun isDuckDuckGoUrl(url: String): Boolean
3535

36+
/**
37+
* This method takes a [url] and returns `true` or `false`.
38+
* @return `true` if the given [url] belongs to the duck.ai domain (apex or subdomain) and `false` otherwise.
39+
*/
40+
fun isDuckAiUrl(url: String): Boolean
41+
3642
/**
3743
* This method takes a [uri] and returns `true` or `false`.
3844
* @return `true` if the given [uri] is a DuckDuckGo query and `false`

common/common-utils/src/main/java/com/duckduckgo/common/utils/AppUrl.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class AppUrl {
2020

2121
object Url {
2222
const val HOST = "duckduckgo.com"
23+
const val HOST_DUCKAI = "duck.ai"
2324
const val API = "https://$HOST"
2425
const val HOME = "https://$HOST"
2526
const val COOKIES = "https://$HOST"

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ interface DuckChatInternal : DuckChat {
181181
*/
182182
fun isImageUploadEnabled(): Boolean
183183

184+
/**
185+
* Returns whether standalone migration is supported.
186+
*/
187+
fun isStandaloneMigrationEnabled(): Boolean
188+
184189
/**
185190
* Returns the time a Duck Chat session should be kept alive
186191
*/
@@ -315,6 +320,7 @@ class RealDuckChat @Inject constructor(
315320
private var isAddressBarEntryPointEnabled: Boolean = false
316321
private var isVoiceSearchEntryPointEnabled: Boolean = false
317322
private var isImageUploadEnabled: Boolean = false
323+
private var isStandaloneMigrationEnabled: Boolean = false
318324
private var keepSessionAliveInMinutes: Int = DEFAULT_SESSION_ALIVE
319325
private var clearChatHistory: Boolean = true
320326
private var inputScreenMainButtonsEnabled = false
@@ -462,6 +468,8 @@ class RealDuckChat @Inject constructor(
462468

463469
override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled
464470

471+
override fun isStandaloneMigrationEnabled(): Boolean = isStandaloneMigrationEnabled
472+
465473
override fun keepSessionIntervalInMinutes() = keepSessionAliveInMinutes
466474

467475
override fun openDuckChat() {
@@ -694,6 +702,7 @@ class RealDuckChat @Inject constructor(
694702
isAddressBarEntryPointEnabled = settingsJson?.addressBarEntryPoint ?: false
695703
isVoiceSearchEntryPointEnabled = duckChatFeature.duckAiVoiceSearch().isEnabled()
696704
isImageUploadEnabled = imageUploadFeature.self().isEnabled()
705+
isStandaloneMigrationEnabled = duckChatFeature.standaloneMigration().isEnabled()
697706

698707
keepSession.value = duckChatFeature.keepSession().isEnabled()
699708
keepSessionAliveInMinutes = settingsJson?.sessionTimeoutMinutes ?: DEFAULT_SESSION_ALIVE

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,12 @@ interface DuckChatFeature {
116116
*/
117117
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
118118
fun duckAiVoiceSearch(): Toggle
119+
120+
121+
/**
122+
* @return `true` when standalone migration is supported
123+
* If the remote feature is not present defaults to `false`
124+
*/
125+
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
126+
fun standaloneMigration(): Toggle
119127
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.duckduckgo.duckchat.impl.helper
1818

19+
import com.duckduckgo.common.utils.DispatcherProvider
1920
import com.duckduckgo.di.scopes.AppScope
2021
import com.duckduckgo.duckchat.impl.ChatState
2122
import com.duckduckgo.duckchat.impl.ChatState.HIDE
@@ -28,6 +29,7 @@ import com.duckduckgo.duckchat.impl.store.DuckChatDataStore
2829
import com.duckduckgo.js.messaging.api.JsCallbackData
2930
import com.squareup.anvil.annotations.ContributesBinding
3031
import kotlinx.coroutines.runBlocking
32+
import kotlinx.coroutines.withContext
3133
import org.json.JSONObject
3234
import java.util.regex.Pattern
3335
import javax.inject.Inject
@@ -47,7 +49,9 @@ class RealDuckChatJSHelper @Inject constructor(
4749
private val duckChatPixels: DuckChatPixels,
4850
private val dataStore: DuckChatDataStore,
4951
private val duckAiMetricCollector: DuckAiMetricCollector,
52+
private val dispatchers: DispatcherProvider,
5053
) : DuckChatJSHelper {
54+
private val migrationItems = mutableListOf<String>()
5155
override suspend fun processJsCallbackMessage(
5256
featureName: String,
5357
method: String,
@@ -117,6 +121,22 @@ class RealDuckChatJSHelper @Inject constructor(
117121
null
118122
}
119123

124+
METHOD_STORE_MIGRATION_DATA -> id?.let {
125+
getStoreMigrationDataResponse(featureName, method, it, data)
126+
}
127+
128+
METHOD_GET_MIGRATION_INFO -> id?.let {
129+
getMigrationInfoResponse(featureName, method, it)
130+
}
131+
132+
METHOD_GET_MIGRATION_DATA_BY_INDEX -> id?.let {
133+
getMigrationDataByIndexResponse(featureName, method, it, data)
134+
}
135+
136+
METHOD_CLEAR_MIGRATION_DATA -> id?.let {
137+
getClearMigrationDataResponse(featureName, method, it)
138+
}
139+
120140
else -> null
121141
}
122142

@@ -148,6 +168,7 @@ class RealDuckChatJSHelper @Inject constructor(
148168
put(SUPPORTS_NATIVE_CHAT_INPUT, false)
149169
put(SUPPORTS_CHAT_ID_RESTORATION, duckChat.isDuckChatFullScreenModeEnabled())
150170
put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled())
171+
put(SUPPORTS_STANDALONE_MIGRATION, duckChat.isStandaloneMigrationEnabled())
151172
}
152173
return JsCallbackData(jsonPayload, featureName, method, id)
153174
}
@@ -191,6 +212,90 @@ class RealDuckChatJSHelper @Inject constructor(
191212
}
192213
}
193214

215+
/**
216+
* Accept incoming JSON payload { "serializedMigrationFile": "..." }
217+
* Store the string value in an ordered list for later retrieval
218+
*/
219+
private suspend fun getStoreMigrationDataResponse(
220+
featureName: String,
221+
method: String,
222+
id: String,
223+
data: JSONObject?,
224+
): JsCallbackData {
225+
return withContext(dispatchers.io()) {
226+
val item = data?.optString(SERIALIZED_MIGRATION_FILE)
227+
val jsonPayload = JSONObject()
228+
if (item != null && item != JSONObject.NULL) {
229+
migrationItems.add(item)
230+
jsonPayload.put(OK, true)
231+
} else {
232+
jsonPayload.put(OK, false)
233+
jsonPayload.put(REASON, "Missing or invalid serializedMigrationFile")
234+
}
235+
JsCallbackData(jsonPayload, featureName, method, id)
236+
}
237+
}
238+
239+
/**
240+
* Return the count of strings previously stored.
241+
* It's ok to return 0 if no items have been stored
242+
*/
243+
private suspend fun getMigrationInfoResponse(
244+
featureName: String,
245+
method: String,
246+
id: String,
247+
): JsCallbackData {
248+
return withContext(dispatchers.io()) {
249+
val count = migrationItems.size
250+
val jsonPayload = JSONObject().apply {
251+
put(OK, true)
252+
put(COUNT, count)
253+
}
254+
JsCallbackData(jsonPayload, featureName, method, id)
255+
}
256+
}
257+
258+
/**
259+
* Try to lookup a string by index
260+
* - when found, return { ok: true, serializedMigrationFile: '...' }
261+
* - when missing, return { ok: false, reason: '...' }
262+
*/
263+
private suspend fun getMigrationDataByIndexResponse(
264+
featureName: String,
265+
method: String,
266+
id: String,
267+
data: JSONObject?,
268+
): JsCallbackData {
269+
return withContext(dispatchers.io()) {
270+
val index = data?.optInt(INDEX, -1) ?: -1
271+
val value = migrationItems.getOrNull(index)
272+
val jsonPayload = JSONObject()
273+
if (value == null) {
274+
jsonPayload.put(OK, false)
275+
jsonPayload.put(REASON, "nothing at index: $index")
276+
} else {
277+
jsonPayload.put(OK, true)
278+
jsonPayload.put(SERIALIZED_MIGRATION_FILE, value)
279+
}
280+
JsCallbackData(jsonPayload, featureName, method, id)
281+
}
282+
}
283+
284+
/**
285+
* Clear migration data, returning { ok: true } when complete
286+
*/
287+
private suspend fun getClearMigrationDataResponse(
288+
featureName: String,
289+
method: String,
290+
id: String,
291+
): JsCallbackData {
292+
return withContext(dispatchers.io()) {
293+
migrationItems.clear()
294+
val jsonPayload = JSONObject().apply { put(OK, true) }
295+
JsCallbackData(jsonPayload, featureName, method, id)
296+
}
297+
}
298+
194299
companion object {
195300
const val DUCK_CHAT_FEATURE_NAME = "aiChat"
196301
private const val METHOD_GET_AI_CHAT_NATIVE_HANDOFF_DATA = "getAIChatNativeHandoffData"
@@ -210,12 +315,25 @@ class RealDuckChatJSHelper @Inject constructor(
210315
private const val SUPPORTS_NATIVE_CHAT_INPUT = "supportsNativeChatInput"
211316
private const val SUPPORTS_IMAGE_UPLOAD = "supportsImageUpload"
212317
private const val SUPPORTS_CHAT_ID_RESTORATION = "supportsURLChatIDRestoration"
318+
private const val SUPPORTS_STANDALONE_MIGRATION = "supportsStandaloneMigration"
213319
private const val REPORT_METRIC = "reportMetric"
214320
private const val PLATFORM = "platform"
215321
private const val ANDROID = "android"
216322
const val SELECTOR = "selector"
217323
private const val DEFAULT_SELECTOR = "'user-prompt'"
218324
private const val SUCCESS = "success"
219325
private const val ERROR = "error"
326+
private const val OK = "ok"
327+
private const val REASON = "reason"
328+
329+
// Migration messaging constants
330+
private const val METHOD_STORE_MIGRATION_DATA = "storeMigrationData"
331+
private const val METHOD_GET_MIGRATION_INFO = "getMigrationInfo"
332+
private const val METHOD_GET_MIGRATION_DATA_BY_INDEX = "getMigrationDataByIndex"
333+
private const val METHOD_CLEAR_MIGRATION_DATA = "clearMigrationData"
334+
335+
private const val SERIALIZED_MIGRATION_FILE = "serializedMigrationFile"
336+
private const val COUNT = "count"
337+
private const val INDEX = "index"
220338
}
221339
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/DuckChatContentScopeJsMessageHandler.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ
4141
override val allowedDomains: List<String> =
4242
listOf(
4343
AppUrl.Url.HOST,
44+
AppUrl.Url.HOST_DUCKAI,
4445
)
4546

4647
override val featureName: String = "aiChat"
@@ -56,6 +57,12 @@ class DuckChatContentScopeJsMessageHandler @Inject constructor() : ContentScopeJ
5657
"showChatInput",
5758
"reportMetric",
5859
"openKeyboard",
60+
61+
// migration handlers
62+
"storeMigrationData",
63+
"getMigrationInfo",
64+
"getMigrationDataByIndex",
65+
"clearMigrationData",
5966
)
6067
}
6168
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewFragment.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import androidx.lifecycle.ViewModelProvider
4747
import androidx.lifecycle.flowWithLifecycle
4848
import androidx.lifecycle.lifecycleScope
4949
import com.duckduckgo.anvil.annotations.InjectWith
50+
import com.duckduckgo.app.browser.DuckDuckGoUrlDetector
5051
import com.duckduckgo.app.di.AppCoroutineScope
5152
import com.duckduckgo.app.tabs.BrowserNav
5253
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
@@ -130,6 +131,9 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
130131
@Inject
131132
lateinit var duckChatJSHelper: DuckChatJSHelper
132133

134+
@Inject
135+
lateinit var duckDuckGoUrlDetector: DuckDuckGoUrlDetector
136+
133137
@Inject
134138
lateinit var subscriptionsHandler: SubscriptionsHandler
135139

@@ -209,7 +213,12 @@ open class DuckChatWebViewFragment : DuckDuckGoFragment(R.layout.activity_duck_c
209213
view?.requestFocusNodeHref(resultMsg)
210214
val newWindowUrl = resultMsg?.data?.getString("url")
211215
if (newWindowUrl != null) {
212-
startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl))
216+
if (duckDuckGoUrlDetector.isDuckAiUrl(newWindowUrl)) {
217+
// Allow Duck.ai links to load within the same WebView (in-sheet navigation)
218+
simpleWebview.loadUrl(newWindowUrl)
219+
} else {
220+
startActivity(browserNav.openInNewTab(requireContext(), newWindowUrl))
221+
}
213222
return true
214223
}
215224
return false

0 commit comments

Comments
 (0)