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 f39c2d299025..ac7b4573b0a1 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 @@ -29,6 +29,7 @@ import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.squareup.anvil.annotations.ContributesBinding import kotlinx.coroutines.runBlocking +import logcat.logcat import org.json.JSONObject import java.util.regex.Pattern import javax.inject.Inject @@ -173,7 +174,8 @@ class RealDuckChatJSHelper @Inject constructor( put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled()) put(SUPPORTS_STANDALONE_MIGRATION, duckChat.isStandaloneMigrationEnabled()) put(SUPPORTS_CHAT_FULLSCREEN_MODE, duckChat.isDuckChatFullScreenModeEnabled()) - } + put(SUPPORTS_CHAT_SYNC, duckChat.isChatSyncFeatureEnabled()) + }.also { logcat { "DuckChat-Sync: getAIChatNativeConfigValues $it" } } return JsCallbackData(jsonPayload, featureName, method, id) } @@ -237,6 +239,7 @@ class RealDuckChatJSHelper @Inject constructor( private const val SUPPORTS_CHAT_ID_RESTORATION = "supportsURLChatIDRestoration" private const val SUPPORTS_STANDALONE_MIGRATION = "supportsStandaloneMigration" private const val SUPPORTS_CHAT_FULLSCREEN_MODE = "supportsAIChatFullMode" + private const val SUPPORTS_CHAT_SYNC = "supportsAIChatSync" private const val REPORT_METRIC = "reportMetric" private const val PLATFORM = "platform" private const val ANDROID = "android" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/Base64UrlExtensions.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/Base64UrlExtensions.kt new file mode 100644 index 000000000000..ec8807ae91b5 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/Base64UrlExtensions.kt @@ -0,0 +1,65 @@ +/* + * 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.sync + +/** + * Converts a standard Base64 string to URL-safe Base64 (base64url) format. + * + * This function performs the following transformations: + * - Replaces '+' with '-' + * - Replaces '/' with '_' + * - Removes trailing padding '=' characters + * + * This is useful when encoding data that needs to be safely transmitted in URLs + * or other contexts where standard Base64 characters might cause issues. + * + * @return A URL-safe Base64 encoded string + */ +internal fun String.applyUrlSafetyFromB64(): String { + return this + .replace('+', '-') + .replace('/', '_') + .trimEnd('=') +} + +/** + * Converts a URL-safe Base64 (base64url) string back to standard Base64 format. + * + * This function performs the following transformations: + * - Replaces '-' with '+' + * - Replaces '_' with '/' + * - Restores padding '=' characters as needed + * + * This is the inverse of [applyUrlSafetyFromB64] and is needed because Android's + * Base64.URL_SAFE flag does not automatically restore missing padding on decode. + * + * @return A standard Base64 encoded string with proper padding + */ +internal fun String.removeUrlSafetyToRestoreB64(): String { + return this + .replace('-', '+') + .replace('_', '/') + .restoreBase64Padding() +} + +private fun String.restoreBase64Padding(): String { + return when (length % 4) { + 2 -> "$this==" + 3 -> "$this=" + else -> this + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandler.kt new file mode 100644 index 000000000000..80a71a668963 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandler.kt @@ -0,0 +1,183 @@ +/* + * 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.sync + +import android.util.Base64 +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.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.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncCrypto +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.LogPriority +import logcat.asLog +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class DecryptWithSyncMasterKeyHandler @Inject constructor( + private val crypto: SyncCrypto, + private val deviceSyncState: DeviceSyncState, +) : ContentScopeJsMessageHandlersPlugin { + + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" } + + val responder = JavaScriptResponder(jsMessaging, jsMessage, featureName) + + val syncError = validateSyncState() + if (syncError != null) { + responder.sendError(syncError).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $syncError" } + } + return + } + + // get encrypted data from params + val data = extractData(jsMessage) + if (data == null) { + responder.sendError(ERROR_INVALID_PARAMETERS).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_INVALID_PARAMETERS" } + } + return + } + + // decode base64Url to get encrypted bytes + val encryptedBytes = decodeBase64Url(data) + if (encryptedBytes == null) { + responder.sendError(ERROR_INVALID_PARAMETERS).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_INVALID_PARAMETERS" } + } + return + } + + // decrypt (input is bytes, output is bytes) + val decryptedBytes = decryptData(encryptedBytes) + if (decryptedBytes == null) { + responder.sendError(ERROR_DECRYPTION_FAILED).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_DECRYPTION_FAILED" } + } + return + } + + // encode decrypted bytes as base64Url for response + val decryptedData = Base64.encodeToString(decryptedBytes, Base64.NO_WRAP).applyUrlSafetyFromB64() + + logcat { "DuckChat-Sync: Decrypted data successfully" } + + // send decrypted data back to JS + responder.sendSuccess(decryptedData) + } + + private fun validateSyncState(): String? { + if (!deviceSyncState.isFeatureEnabled()) { + return ERROR_SYNC_DISABLED + } + if (deviceSyncState.getAccountState() !is SignedIn) { + return ERROR_SYNC_OFF + } + return null + } + + private fun extractData(jsMessage: JsMessage): String? { + val data = jsMessage.params.optString("data", "") + return data.takeIf { it.isNotEmpty() } + } + + private fun decodeBase64Url(base64Url: String): ByteArray? { + return runCatching { + val standardB64 = base64Url.removeUrlSafetyToRestoreB64() + Base64.decode(standardB64, Base64.NO_WRAP) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "Error decoding base64Url: $base64Url. ${e.asLog()}" } + }.getOrNull() + } + + private fun decryptData(data: ByteArray): ByteArray? { + return runCatching { + crypto.decrypt(data) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "Error decrypting data because ${e.asLog()}" } + }.getOrNull() + } + + override val allowedDomains: List = listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("decryptWithSyncMasterKey") + } + + private class JavaScriptResponder( + private val jsMessaging: JsMessaging, + private val jsMessage: JsMessage, + private val featureName: String, + ) { + fun sendSuccess(decryptedData: String) { + val payload = JSONObject().apply { + put("decryptedData", decryptedData) + } + val jsonPayload = JSONObject().apply { + put("ok", true) + put("payload", payload) + } + runCatching { + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send success response: ${e.message}" } + } + } + + fun sendError(error: String) { + val errorPayload = JSONObject().apply { + put("ok", false) + put("reason", error) + } + runCatching { + jsMessaging.onResponse(JsCallbackData(errorPayload, featureName, jsMessage.method, jsMessage.id!!)) + logcat { "DuckChat-Sync: error: $error" } + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send error response: ${e.message}" } + } + } + } + + private companion object { + private const val ERROR_SYNC_DISABLED = "sync unavailable" + private const val ERROR_SYNC_OFF = "sync off" + private const val ERROR_INVALID_PARAMETERS = "invalid parameters" + private const val ERROR_DECRYPTION_FAILED = "decryption failed" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandler.kt new file mode 100644 index 000000000000..894f02519620 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandler.kt @@ -0,0 +1,183 @@ +/* + * 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.sync + +import android.util.Base64 +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.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.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncCrypto +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.LogPriority +import logcat.asLog +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class EncryptWithSyncMasterKeyHandler @Inject constructor( + private val crypto: SyncCrypto, + private val deviceSyncState: DeviceSyncState, +) : ContentScopeJsMessageHandlersPlugin { + + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" } + + val responder = JavaScriptResponder(jsMessaging, jsMessage, featureName) + + val syncError = validateSyncState() + if (syncError != null) { + responder.sendError(syncError).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $syncError" } + } + return + } + + // get raw data from params + val data = extractData(jsMessage) + if (data == null) { + responder.sendError(ERROR_INVALID_PARAMETERS).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_INVALID_PARAMETERS" } + } + return + } + + // need to first decode base64Url + val decodedBytes = decodeBase64Url(data) + if (decodedBytes == null) { + responder.sendError(ERROR_INVALID_PARAMETERS).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_INVALID_PARAMETERS" } + } + return + } + + // then we encrypt + val encryptedBytes = encryptData(decodedBytes) + if (encryptedBytes == null) { + responder.sendError(ERROR_ENCRYPTION_FAILED).also { + logcat(LogPriority.WARN) { "DuckChat-Sync: $ERROR_ENCRYPTION_FAILED" } + } + return + } + + // encode encrypted bytes as base64Url for response + val encryptedData = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP).applyUrlSafetyFromB64() + + logcat { "DuckChat-Sync: Encrypted data successfully" } + + // send encrypted data back to JS + responder.sendSuccess(encryptedData) + } + + override val allowedDomains: List = listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("encryptWithSyncMasterKey") + } + + private fun validateSyncState(): String? { + if (!deviceSyncState.isFeatureEnabled()) { + return ERROR_SYNC_DISABLED + } + if (deviceSyncState.getAccountState() !is SignedIn) { + return ERROR_SYNC_OFF + } + return null + } + + private fun extractData(jsMessage: JsMessage): String? { + val data = jsMessage.params.optString("data", "") + return data.takeIf { it.isNotEmpty() } + } + + private fun decodeBase64Url(base64Url: String): ByteArray? { + return runCatching { + val standardB64 = base64Url.removeUrlSafetyToRestoreB64() + Base64.decode(standardB64, Base64.NO_WRAP) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "Error decoding base64Url: $base64Url. ${e.asLog()}" } + }.getOrNull() + } + + private fun encryptData(data: ByteArray): ByteArray? { + return runCatching { + crypto.encrypt(data) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "Error encrypting byte array [${data.size} bytes] because ${e.asLog()}" } + }.getOrNull() + } + + private class JavaScriptResponder( + private val jsMessaging: JsMessaging, + private val jsMessage: JsMessage, + private val featureName: String, + ) { + fun sendSuccess(encryptedData: String) { + val payload = JSONObject().apply { + put("encryptedData", encryptedData) + } + val jsonPayload = JSONObject().apply { + put("ok", true) + put("payload", payload) + } + runCatching { + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)) + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send success response: ${e.message}" } + } + } + + fun sendError(error: String) { + val errorPayload = JSONObject().apply { + put("ok", false) + put("reason", error) + } + runCatching { + jsMessaging.onResponse(JsCallbackData(errorPayload, featureName, jsMessage.method, jsMessage.id!!)) + logcat { "DuckChat-Sync: error: $error" } + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send error response: ${e.message}" } + } + } + } + + private companion object { + private const val ERROR_SYNC_DISABLED = "sync unavailable" + private const val ERROR_SYNC_OFF = "sync off" + private const val ERROR_INVALID_PARAMETERS = "invalid parameters" + private const val ERROR_ENCRYPTION_FAILED = "encryption failed" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandler.kt new file mode 100644 index 000000000000..aa86700be374 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandler.kt @@ -0,0 +1,83 @@ +/* + * 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.sync + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.common.utils.DispatcherProvider +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.repository.DuckChatFeatureRepository +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.ContributesMultibinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class SetAIChatHistoryEnabledHandler @Inject constructor( + private val duckChatFeatureRepository: DuckChatFeatureRepository, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (!jsMessage.params.has(PARAM_ENABLED)) { + logcat(LogPriority.ERROR) { "DuckChat-Sync: ${jsMessage.method} called without 'enabled' parameter, taking no action" } + return + } + + val enabledValue = jsMessage.params.opt(PARAM_ENABLED) + if (enabledValue !is Boolean) { + logcat(LogPriority.ERROR) { "DuckChat-Sync: ${jsMessage.method} called with invalid 'enabled' parameter type, expected boolean" } + return + } + + val enabled: Boolean = enabledValue + + logcat { "DuckChat-Sync: ${jsMessage.method} processing with enabled=$enabled" } + + appCoroutineScope.launch(dispatchers.io()) { + duckChatFeatureRepository.setAIChatHistoryEnabled(enabled) + } + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("setAIChatHistoryEnabled") + } + + private companion object { + private const val PARAM_ENABLED = "enabled" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandler.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandler.kt new file mode 100644 index 000000000000..2d322ef6e6e8 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandler.kt @@ -0,0 +1,113 @@ +/* + * 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.sync + +import android.content.Context +import android.content.Intent +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.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.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncActivityWithEmptyParams +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class SetUpSyncHandler @Inject constructor( + private val globalActivityStarter: GlobalActivityStarter, + private val context: Context, + private val deviceSyncState: DeviceSyncState, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" } + + val responder = JavaScriptResponder(jsMessaging, jsMessage, featureName) + + val setupError = validateSetupState() + if (setupError != null) { + responder.sendError(setupError) + return + } + + globalActivityStarter.startIntent(context, SyncActivityWithEmptyParams)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }?.let { context.startActivity(it) } + } + + private fun validateSetupState(): String? { + if (!deviceSyncState.isFeatureEnabled()) { + return ERROR_SETUP_UNAVAILABLE + } + if (deviceSyncState.getAccountState() is SignedIn) { + return ERROR_SYNC_ALREADY_ON + } + return null + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("sendToSyncSettings", "sendToSetupSync") + } + + private class JavaScriptResponder( + private val jsMessaging: JsMessaging, + private val jsMessage: JsMessage, + private val featureName: String, + ) { + fun sendError(error: String) { + val errorPayload = JSONObject().apply { + put("ok", false) + put("reason", error) + } + runCatching { + jsMessaging.onResponse(JsCallbackData(errorPayload, featureName, jsMessage.method, jsMessage.id!!)) + logcat { "DuckChat-Sync: error: $error" } + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send error response: ${e.message}" } + } + } + } + + private companion object { + private const val ERROR_SETUP_UNAVAILABLE = "setup unavailable" + private const val ERROR_SYNC_ALREADY_ON = "sync already on" + } +} diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/repository/DuckChatFeatureRepository.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/repository/DuckChatFeatureRepository.kt index e2eab784510a..9e0380931c9e 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/repository/DuckChatFeatureRepository.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/repository/DuckChatFeatureRepository.kt @@ -70,6 +70,14 @@ interface DuckChatFeatureRepository { suspend fun lastSessionTimestamp(): Long suspend fun sessionDeltaInMinutes(): Long + + suspend fun setAppBackgroundTimestamp(timestamp: Long?) + + suspend fun getAppBackgroundTimestamp(): Long? + + suspend fun setAIChatHistoryEnabled(enabled: Boolean) + + suspend fun isAIChatHistoryEnabled(): Boolean } @SingleInstanceIn(AppScope::class) @@ -139,6 +147,18 @@ class RealDuckChatFeatureRepository @Inject constructor( override suspend fun sessionDeltaInMinutes(): Long = duckChatDataStore.sessionDeltaTimestamp() / MS_TO_MINUTES + override suspend fun setAppBackgroundTimestamp(timestamp: Long?) { + duckChatDataStore.setAppBackgroundTimestamp(timestamp) + } + + override suspend fun getAppBackgroundTimestamp(): Long? = duckChatDataStore.getAppBackgroundTimestamp() + + override suspend fun setAIChatHistoryEnabled(enabled: Boolean) { + duckChatDataStore.setAIChatHistoryEnabled(enabled) + } + + override suspend fun isAIChatHistoryEnabled(): Boolean = duckChatDataStore.isAIChatHistoryEnabled() + private fun updateWidgets() { val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) context.sendBroadcast(intent) diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/store/DuckChatDataStore.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/store/DuckChatDataStore.kt index 4fa84a4215ff..9fb774a09351 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/store/DuckChatDataStore.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/store/DuckChatDataStore.kt @@ -29,7 +29,9 @@ import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.di.DuckChat import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_AI_INPUT_SCREEN_COSMETIC_SETTING import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_AI_INPUT_SCREEN_USER_SETTING +import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_BACKGROUND_TIMESTAMP import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_FULLSCREEN_MODE_SETTING +import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_HISTORY_ENABLED import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_LAST_SESSION_TIMESTAMP import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_OPENED import com.duckduckgo.duckchat.impl.store.SharedPreferencesDuckChatDataStore.Keys.DUCK_CHAT_SESSION_DELTA_TIMESTAMP @@ -103,6 +105,14 @@ interface DuckChatDataStore { suspend fun lastSessionTimestamp(): Long suspend fun sessionDeltaTimestamp(): Long + + suspend fun setAppBackgroundTimestamp(timestamp: Long?) + + suspend fun getAppBackgroundTimestamp(): Long? + + suspend fun setAIChatHistoryEnabled(enabled: Boolean) + + suspend fun isAIChatHistoryEnabled(): Boolean } @ContributesBinding(AppScope::class) @@ -125,6 +135,8 @@ class SharedPreferencesDuckChatDataStore @Inject constructor( val DUCK_CHAT_LAST_SESSION_TIMESTAMP = longPreferencesKey(name = "DUCK_CHAT_LAST_SESSION_TIMESTAMP") val DUCK_CHAT_SESSION_DELTA_TIMESTAMP = longPreferencesKey(name = "DUCK_CHAT_SESSION_DELTA_TIMESTAMP") val DUCK_CHAT_FULLSCREEN_MODE_SETTING = booleanPreferencesKey(name = "DUCK_CHAT_FULLSCREEN_MODE_SETTING") + val DUCK_CHAT_BACKGROUND_TIMESTAMP = longPreferencesKey(name = "DUCK_CHAT_BACKGROUND_TIMESTAMP") + val DUCK_CHAT_HISTORY_ENABLED = booleanPreferencesKey(name = "DUCK_CHAT_HISTORY_ENABLED") } private fun Preferences.defaultShowInAddressBar(): Boolean = @@ -278,4 +290,22 @@ class SharedPreferencesDuckChatDataStore @Inject constructor( override suspend fun lastSessionTimestamp(): Long = store.data.firstOrNull()?.let { it[DUCK_CHAT_LAST_SESSION_TIMESTAMP] } ?: 0L override suspend fun sessionDeltaTimestamp(): Long = store.data.firstOrNull()?.let { it[DUCK_CHAT_SESSION_DELTA_TIMESTAMP] } ?: 0L + + override suspend fun setAppBackgroundTimestamp(timestamp: Long?) { + store.edit { prefs -> + if (timestamp == null) { + prefs.remove(DUCK_CHAT_BACKGROUND_TIMESTAMP) + } else { + prefs[DUCK_CHAT_BACKGROUND_TIMESTAMP] = timestamp + } + } + } + + override suspend fun getAppBackgroundTimestamp(): Long? = store.data.firstOrNull()?.let { it[DUCK_CHAT_BACKGROUND_TIMESTAMP] } + + override suspend fun setAIChatHistoryEnabled(enabled: Boolean) { + store.edit { prefs -> prefs[DUCK_CHAT_HISTORY_ENABLED] = enabled } + } + + override suspend fun isAIChatHistoryEnabled(): Boolean = store.data.firstOrNull()?.let { it[DUCK_CHAT_HISTORY_ENABLED] } ?: false } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckAiChatDeletionListenerImpl.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckAiChatDeletionListenerImpl.kt index a259bd9de9d5..e92070d41eb2 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckAiChatDeletionListenerImpl.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckAiChatDeletionListenerImpl.kt @@ -18,10 +18,15 @@ package com.duckduckgo.duckchat.impl.sync import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.browser.api.DuckAiChatDeletionListener +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import logcat.logcat import javax.inject.Inject @@ -30,24 +35,30 @@ import javax.inject.Inject @ContributesMultibinding(AppScope::class, boundType = MainProcessLifecycleObserver::class) class DuckAiChatDeletionListenerImpl @Inject constructor( private val duckChatSyncRepository: DuckChatSyncRepository, + private val duckChatFeatureRepository: DuckChatFeatureRepository, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : DuckAiChatDeletionListener, MainProcessLifecycleObserver { - @Volatile - private var appBackgroundedTimestamp: Long? = null - override fun onStop(owner: LifecycleOwner) { - appBackgroundedTimestamp = System.currentTimeMillis() - logcat { "DuckChat-Sync: App went to background, stored timestamp: $appBackgroundedTimestamp" } + val timestamp = System.currentTimeMillis() + appCoroutineScope.launch(dispatchers.io()) { + duckChatFeatureRepository.setAppBackgroundTimestamp(timestamp) + } + logcat { "DuckChat-Sync: App went to background, stored timestamp: $timestamp" } } override fun onStart(owner: LifecycleOwner) { - appBackgroundedTimestamp = null + appCoroutineScope.launch(dispatchers.io()) { + duckChatFeatureRepository.setAppBackgroundTimestamp(null) + } logcat { "DuckChat-Sync: App came to foreground, cleared background timestamp" } } override suspend fun onDuckAiChatsDeleted() { - val timestamp = appBackgroundedTimestamp ?: System.currentTimeMillis() - logcat { "DuckChat-Sync: Duck AI chats deleted, using timestamp: $timestamp (background: ${appBackgroundedTimestamp != null})" } + val backgroundTimestamp = duckChatFeatureRepository.getAppBackgroundTimestamp() + val timestamp = backgroundTimestamp ?: System.currentTimeMillis() + logcat { "DuckChat-Sync: Duck AI chats deleted, using timestamp: $timestamp (background: ${backgroundTimestamp != null})" } duckChatSyncRepository.recordDuckAiChatsDeleted(timestamp) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManager.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManager.kt index 581937b7c81c..41e829c646bc 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManager.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManager.kt @@ -23,6 +23,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.checkMainThread import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import com.duckduckgo.sync.api.engine.DeletableDataManager import com.duckduckgo.sync.api.engine.DeletableType import com.duckduckgo.sync.api.engine.SyncDeletionRequest @@ -39,6 +40,7 @@ import javax.inject.Inject @ContributesMultibinding(scope = AppScope::class, boundType = DeletableDataManager::class) class DuckChatSyncDataManager @Inject constructor( private val duckChatSyncRepository: DuckChatSyncRepository, + private val duckChatFeatureRepository: DuckChatFeatureRepository, private val dispatchers: DispatcherProvider, private val appBuildConfig: AppBuildConfig, private val duckChatFeature: DuckChatFeature, @@ -50,12 +52,17 @@ class DuckChatSyncDataManager @Inject constructor( override fun getDeletions(): SyncDeletionRequest? { if (appBuildConfig.isInternalBuild()) checkMainThread() - if (!duckChatFeature.supportsSyncChatsDeletion().isEnabled()) { - logcat { "DuckChat-Sync: Duck AI chat sync disabled, skipping deletion check" } - return null - } - return runBlocking(dispatchers.io()) { + if (!duckChatFeature.supportsSyncChatsDeletion().isEnabled()) { + logcat { "DuckChat-Sync: Duck AI chat sync disabled, skipping deletion check" } + return@runBlocking null + } + + if (!duckChatFeatureRepository.isAIChatHistoryEnabled()) { + logcat { "DuckChat-Sync: Chat history disabled, skipping deletion check" } + return@runBlocking null + } + val deletionTimestamp = duckChatSyncRepository.getLastDuckAiChatDeletionTimestamp() formatRequest(deletionTimestamp) } diff --git a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManagerTest.kt b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManagerTest.kt index 24acdc3118e5..2f1c1857edfe 100644 --- a/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManagerTest.kt +++ b/duckchat/duckchat-impl/src/test/java/com/duckduckgo/duckchat/impl/sync/DuckChatSyncDataManagerTest.kt @@ -20,6 +20,7 @@ import android.annotation.SuppressLint import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.sync.api.engine.DeletableType @@ -45,6 +46,8 @@ class DuckChatSyncDataManagerTest { private val duckChatSyncRepository: DuckChatSyncRepository = mock() + private val duckChatFeatureRepository: DuckChatFeatureRepository = mock() + private val appBuildConfig: AppBuildConfig = mock() private lateinit var testee: DuckChatSyncDataManager @@ -52,9 +55,11 @@ class DuckChatSyncDataManagerTest { private val duckChatFeature = FakeFeatureToggleFactory.create(DuckChatFeature::class.java) @Before - fun setUp() { + fun setUp() = runTest { + whenever(duckChatFeatureRepository.isAIChatHistoryEnabled()).thenReturn(true) testee = DuckChatSyncDataManager( duckChatSyncRepository = duckChatSyncRepository, + duckChatFeatureRepository = duckChatFeatureRepository, dispatchers = coroutineTestRule.testDispatcherProvider, appBuildConfig = appBuildConfig, duckChatFeature = duckChatFeature, @@ -74,9 +79,20 @@ class DuckChatSyncDataManagerTest { assertNull(result) } + @Test + fun whenGetDeletionsAndChatHistoryDisabledThenReturnsNull() = runTest { + duckChatFeature.supportsSyncChatsDeletion().setRawStoredState(Toggle.State(enable = true)) + whenever(duckChatFeatureRepository.isAIChatHistoryEnabled()).thenReturn(false) + + val result = testee.getDeletions() + + assertNull(result) + } + @Test fun whenGetDeletionsAndFeatureEnabledWithTimestampThenReturnsDeletionRequest() = runTest { duckChatFeature.supportsSyncChatsDeletion().setRawStoredState(Toggle.State(enable = true)) + whenever(duckChatFeatureRepository.isAIChatHistoryEnabled()).thenReturn(true) whenever(duckChatSyncRepository.getLastDuckAiChatDeletionTimestamp()).thenReturn("2025-01-01T12:00:00Z") val result = testee.getDeletions() @@ -88,6 +104,7 @@ class DuckChatSyncDataManagerTest { @Test fun whenGetDeletionsAndFeatureEnabledWithoutTimestampThenReturnsNull() = runTest { duckChatFeature.supportsSyncChatsDeletion().setRawStoredState(Toggle.State(enable = true)) + whenever(duckChatFeatureRepository.isAIChatHistoryEnabled()).thenReturn(true) whenever(duckChatSyncRepository.getLastDuckAiChatDeletionTimestamp()).thenReturn(null) val result = testee.getDeletions() assertNull(result) 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 7bd48b9fb61e..b4daf0bf59d1 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 @@ -188,6 +188,7 @@ class RealDuckChatJSHelperTest { put("supportsImageUpload", false) put("supportsStandaloneMigration", false) put("supportsAIChatFullMode", false) + put("supportsAIChatSync", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -219,6 +220,7 @@ class RealDuckChatJSHelperTest { put("supportsImageUpload", false) put("supportsStandaloneMigration", false) put("supportsAIChatFullMode", false) + put("supportsAIChatSync", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -250,6 +252,7 @@ class RealDuckChatJSHelperTest { put("supportsImageUpload", false) put("supportsStandaloneMigration", false) put("supportsAIChatFullMode", true) + put("supportsAIChatSync", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -280,6 +283,7 @@ class RealDuckChatJSHelperTest { put("supportsImageUpload", false) put("supportsStandaloneMigration", true) put("supportsAIChatFullMode", false) + put("supportsAIChatSync", false) } val expected = JsCallbackData(jsonPayload, featureName, method, id) @@ -444,6 +448,35 @@ class RealDuckChatJSHelperTest { put("supportsImageUpload", true) put("supportsStandaloneMigration", false) put("supportsAIChatFullMode", false) + put("supportsAIChatSync", false) + } + + assertEquals(expectedPayload.toString(), result!!.params.toString()) + } + + @Test + fun whenGetAIChatNativeConfigValuesAndChatSyncEnabledThenReturnJsCallbackDataWithSupportsAIChatSyncEnabled() = runTest { + val featureName = "aiChat" + val method = "getAIChatNativeConfigValues" + val id = "123" + + whenever(mockDuckChat.isDuckChatFeatureEnabled()).thenReturn(true) + whenever(mockDuckChat.isDuckChatFullScreenModeEnabled()).thenReturn(false) + whenever(mockDuckChat.isChatSyncFeatureEnabled()).thenReturn(true) + + val result = testee.processJsCallbackMessage(featureName, method, id, null) + + val expectedPayload = JSONObject().apply { + put("platform", "android") + put("isAIChatHandoffEnabled", true) + put("supportsClosingAIChat", true) + put("supportsOpeningSettings", true) + put("supportsNativeChatInput", false) + put("supportsURLChatIDRestoration", false) + put("supportsImageUpload", false) + put("supportsStandaloneMigration", false) + put("supportsAIChatFullMode", false) + put("supportsAIChatSync", true) } assertEquals(expectedPayload.toString(), result!!.params.toString()) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandlerTest.kt new file mode 100644 index 000000000000..f1081c9c4f2d --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/DecryptWithSyncMasterKeyHandlerTest.kt @@ -0,0 +1,248 @@ +/* + * 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.sync + +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncCrypto +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class DecryptWithSyncMasterKeyHandlerTest { + + private val mockCrypto: SyncCrypto = mock() + private val mockDeviceSyncState: DeviceSyncState = mock() + private val mockJsMessaging: JsMessaging = mock() + + val callbackDataCaptor = argumentCaptor() + + private lateinit var handler: DecryptWithSyncMasterKeyHandler + + @Before + fun setUp() { + handler = DecryptWithSyncMasterKeyHandler( + crypto = mockCrypto, + deviceSyncState = mockDeviceSyncState, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals(HOST_DUCK_AI, domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals(FEATURE_NAME, handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns decryptWithSyncMasterKey`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(1, methods.size) + assertEquals(METHOD_NAME, methods[0]) + } + + @Test + fun `when id is null then no response is sent`() { + val jsMessage = createJsMessage(null, JSONObject()) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when id is empty then no response is sent`() { + val jsMessage = createJsMessage("", JSONObject()) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when sync is disabled then error response is sent`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", ENCRYPTED_BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifyErrorResponse(response.params, "sync unavailable") + } + + @Test + fun `when sync is off then error response is sent`() { + configureSyncEnabled() + whenever(mockDeviceSyncState.getAccountState()).thenReturn(DeviceSyncState.SyncAccountState.SignedOut) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", ENCRYPTED_BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "sync off") + } + + @Test + fun `when data is missing then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject()) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when data is empty then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", "") }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when base64Url decode fails then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", "invalid-base64-url!") }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when decryption fails then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockCrypto.decrypt(any())).thenThrow(RuntimeException("Decryption failed")) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", ENCRYPTED_BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "decryption failed") + } + + @Test + fun `when decryption succeeds then success response with decrypted data is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockCrypto.decrypt(any())).thenReturn(DECRYPTED_BYTES) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", ENCRYPTED_BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifySuccessResponse(response.params) + } + + private fun configureSyncEnabled() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + } + + private fun configureSignedIn() { + whenever(mockDeviceSyncState.getAccountState()).thenReturn(SignedIn(emptyList())) + } + + private fun verifyNoResponse() { + verifyNoInteractions(mockJsMessaging) + verifyNoInteractions(mockCrypto) + verifyNoInteractions(mockDeviceSyncState) + } + + private fun verifySuccessResponse(params: JSONObject) { + assertTrue(params.getBoolean("ok")) + val payload = params.getJSONObject("payload") + val decryptedData = payload.getString("decryptedData") + + // Verify it matches expected decrypted base64Url + assertEquals(DECRYPTED_BASE64_URL, decryptedData) + } + + private fun verifyErrorResponse(params: JSONObject, expectedReason: String) { + assertFalse(params.getBoolean("ok")) + assertEquals(expectedReason, params.getString("reason")) + } + + private fun createJsMessage(id: String?, params: JSONObject): JsMessage { + return JsMessage( + context = "test", + featureName = FEATURE_NAME, + method = METHOD_NAME, + id = id, + params = params, + ) + } + + companion object { + private const val TEST_MESSAGE_ID = "test-id" + private const val FEATURE_NAME = "aiChat" + private const val METHOD_NAME = "decryptWithSyncMasterKey" + + // Encrypted bytes (mock) - just use different bytes for testing + private val ENCRYPTED_BYTES = byteArrayOf(1, 2, 3, 4, 5) + + // Encrypted bytes as base64Url: using same utility function as production code + private val ENCRYPTED_BASE64_URL_DATA = Base64.encodeToString(ENCRYPTED_BYTES, Base64.NO_WRAP).applyUrlSafetyFromB64() + + // Decrypted bytes (mock) - "Hello" as bytes + private val DECRYPTED_BYTES = byteArrayOf(72, 101, 108, 108, 111) + + // Decrypted bytes as base64Url: using same utility function as production code + private val DECRYPTED_BASE64_URL = Base64.encodeToString(DECRYPTED_BYTES, Base64.NO_WRAP).applyUrlSafetyFromB64() + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandlerTest.kt new file mode 100644 index 000000000000..a0c139d4c0ee --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/EncryptWithSyncMasterKeyHandlerTest.kt @@ -0,0 +1,248 @@ +/* + * 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.sync + +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncCrypto +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class EncryptWithSyncMasterKeyHandlerTest { + + private val mockCrypto: SyncCrypto = mock() + private val mockDeviceSyncState: DeviceSyncState = mock() + private val mockJsMessaging: JsMessaging = mock() + + val callbackDataCaptor = argumentCaptor() + + private lateinit var handler: EncryptWithSyncMasterKeyHandler + + @Before + fun setUp() { + handler = EncryptWithSyncMasterKeyHandler( + crypto = mockCrypto, + deviceSyncState = mockDeviceSyncState, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals(HOST_DUCK_AI, domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals(FEATURE_NAME, handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns encryptWithSyncMasterKey`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(1, methods.size) + assertEquals(METHOD_NAME, methods[0]) + } + + @Test + fun `when id is null then no response is sent`() { + val jsMessage = createJsMessage(null, JSONObject()) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when id is empty then no response is sent`() { + val jsMessage = createJsMessage("", JSONObject()) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when sync is disabled then error response is sent`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifyErrorResponse(response.params, "sync unavailable") + } + + @Test + fun `when sync is off then error response is sent`() { + configureSyncEnabled() + whenever(mockDeviceSyncState.getAccountState()).thenReturn(DeviceSyncState.SyncAccountState.SignedOut) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "sync off") + } + + @Test + fun `when data is missing then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject()) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when data is empty then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", "") }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when base64Url decode fails then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", "invalid-base64-url!") }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "invalid parameters") + } + + @Test + fun `when encryption fails then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockCrypto.encrypt(any())).thenThrow(RuntimeException("Encryption failed")) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "encryption failed") + } + + @Test + fun `when encryption succeeds then success response with encrypted data is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockCrypto.encrypt(any())).thenReturn(ENCRYPTED_BYTES) + val jsMessage = createJsMessage(TEST_MESSAGE_ID, JSONObject().apply { put("data", BASE64_URL_DATA) }) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifySuccessResponse(response.params) + } + + private fun configureSyncEnabled() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + } + + private fun configureSignedIn() { + whenever(mockDeviceSyncState.getAccountState()).thenReturn(SignedIn(emptyList())) + } + + private fun verifyNoResponse() { + verifyNoInteractions(mockJsMessaging) + verifyNoInteractions(mockCrypto) + verifyNoInteractions(mockDeviceSyncState) + } + + private fun verifySuccessResponse(params: JSONObject) { + assertTrue(params.getBoolean("ok")) + val payload = params.getJSONObject("payload") + val encryptedData = payload.getString("encryptedData") + + // Verify it's base64Url encoded (contains - or _ instead of + or /) + assertTrue(encryptedData.contains("-") || encryptedData.contains("_") || encryptedData.matches(Regex("[A-Za-z0-9_-]+"))) + + // Verify it matches expected encrypted base64Url + assertEquals(ENCRYPTED_BASE64_URL, encryptedData) + } + + private fun verifyErrorResponse(params: JSONObject, expectedReason: String) { + assertFalse(params.getBoolean("ok")) + assertEquals(expectedReason, params.getString("reason")) + } + + private fun createJsMessage(id: String?, params: JSONObject): JsMessage { + return JsMessage( + context = "test", + featureName = FEATURE_NAME, + method = METHOD_NAME, + id = id, + params = params, + ) + } + + companion object { + private const val TEST_MESSAGE_ID = "test-id" + private const val FEATURE_NAME = "aiChat" + private const val METHOD_NAME = "encryptWithSyncMasterKey" + + // Test data: "Hello" as base64Url + private const val BASE64_URL_DATA = "SGVsbG8" + + // Encrypted bytes (mock) - just use different bytes for testing + private val ENCRYPTED_BYTES = byteArrayOf(1, 2, 3, 4, 5) + + // Encrypted bytes as base64Url: using same utility function as production code + private val ENCRYPTED_BASE64_URL = Base64.encodeToString(ENCRYPTED_BYTES, Base64.NO_WRAP).applyUrlSafetyFromB64() + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandlerTest.kt new file mode 100644 index 000000000000..4cacda3cebab --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetAIChatHistoryEnabledHandlerTest.kt @@ -0,0 +1,110 @@ +/* + * 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.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI +import com.duckduckgo.duckchat.impl.messaging.fakes.FakeJsMessaging +import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository +import com.duckduckgo.js.messaging.api.JsMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +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.verify +import org.mockito.kotlin.verifyNoInteractions + +@RunWith(AndroidJUnit4::class) +class SetAIChatHistoryEnabledHandlerTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val mockDuckChatFeatureRepository: DuckChatFeatureRepository = mock() + private val dispatchers: DispatcherProvider = coroutineTestRule.testDispatcherProvider + private val appCoroutineScope: CoroutineScope = coroutineTestRule.testScope + private val fakeJsMessaging = FakeJsMessaging() + + private lateinit var handler: SetAIChatHistoryEnabledHandler + + @Before + fun setUp() { + handler = SetAIChatHistoryEnabledHandler( + duckChatFeatureRepository = mockDuckChatFeatureRepository, + dispatchers = dispatchers, + appCoroutineScope = appCoroutineScope, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals(HOST_DUCK_AI, domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals("aiChat", handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns setAIChatHistoryEnabled`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(1, methods.size) + assertEquals("setAIChatHistoryEnabled", methods[0]) + } + + @Test + fun `when enabled parameter is missing then repository is not called`() = runTest { + val jsMessage = createJsMessage(JSONObject()) + handler.getJsMessageHandler().process(jsMessage, fakeJsMessaging, null) + verifyNoInteractions(mockDuckChatFeatureRepository) + } + + @Test + fun `when enabled parameter is true then repository is called with true`() = runTest { + val jsMessage = createJsMessage(JSONObject().apply { put("enabled", true) }) + handler.getJsMessageHandler().process(jsMessage, fakeJsMessaging, null) + verify(mockDuckChatFeatureRepository).setAIChatHistoryEnabled(true) + } + + @Test + fun `when enabled parameter is false then repository is called with false`() = runTest { + val jsMessage = createJsMessage(JSONObject().apply { put("enabled", false) }) + handler.getJsMessageHandler().process(jsMessage, fakeJsMessaging, null) + verify(mockDuckChatFeatureRepository).setAIChatHistoryEnabled(false) + } + + private fun createJsMessage(params: JSONObject): JsMessage { + return JsMessage( + context = "test", + featureName = "aiChat", + method = "setAIChatHistoryEnabled", + id = "test-id", + params = params, + ) + } +} diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandlerTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandlerTest.kt new file mode 100644 index 000000000000..926a91311e67 --- /dev/null +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/sync/SetUpSyncHandlerTest.kt @@ -0,0 +1,209 @@ +/* + * 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.sync + +import android.content.Context +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.duckchat.impl.DuckChatConstants.HOST_DUCK_AI +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.api.SyncActivityWithEmptyParams +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SetUpSyncHandlerTest { + + private val mockGlobalActivityStarter: GlobalActivityStarter = mock() + private val mockContext: Context = mock() + private val mockDeviceSyncState: DeviceSyncState = mock() + private val mockJsMessaging: JsMessaging = mock() + + val callbackDataCaptor = argumentCaptor() + + private lateinit var handler: SetUpSyncHandler + + @Before + fun setUp() { + handler = SetUpSyncHandler( + globalActivityStarter = mockGlobalActivityStarter, + context = mockContext, + deviceSyncState = mockDeviceSyncState, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals(HOST_DUCK_AI, domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals("aiChat", handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns sendToSyncSettings and sendToSetupSync`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(2, methods.size) + assertEquals("sendToSyncSettings", methods[0]) + assertEquals("sendToSetupSync", methods[1]) + } + + @Test + fun `when id is null then activity is not started`() { + val jsMessage = createJsMessage("sendToSyncSettings", null) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verifyNoInteractions(mockGlobalActivityStarter) + verifyNoInteractions(mockContext) + verifyNoInteractions(mockJsMessaging) + } + + @Test + fun `when id is empty then activity is not started`() { + val jsMessage = createJsMessage("sendToSyncSettings", "") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verifyNoInteractions(mockGlobalActivityStarter) + verifyNoInteractions(mockContext) + verifyNoInteractions(mockJsMessaging) + } + + @Test + fun `when sync setup is disabled then error response is sent`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + val jsMessage = createJsMessage("sendToSetupSync", "test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals("test-id", response.id) + assertEquals("aiChat", response.featureName) + assertEquals("sendToSetupSync", response.method) + verifyErrorResponse(response.params, "setup unavailable") + verifyNoInteractions(mockGlobalActivityStarter) + verifyNoInteractions(mockContext) + } + + @Test + fun `when sync is already set up then error response is sent`() { + configureSyncEnabled() + whenever(mockDeviceSyncState.getAccountState()).thenReturn(SignedIn(emptyList())) + val jsMessage = createJsMessage("sendToSetupSync", "test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "sync already on") + verifyNoInteractions(mockGlobalActivityStarter) + verifyNoInteractions(mockContext) + } + + @Test + fun `when id is present and startIntent returns intent then activity is started with new task flag`() { + configureSyncEnabled() + configureSignedOut() + val intent = Intent() + whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(intent) + val jsMessage = createJsMessage("sendToSyncSettings", "test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockGlobalActivityStarter).startIntent(mockContext, SyncActivityWithEmptyParams) + assertTrue("FLAG_ACTIVITY_NEW_TASK should be set", intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK != 0) + verify(mockContext).startActivity(intent) + verifyNoInteractions(mockJsMessaging) + } + + @Test + fun `when id is present and startIntent returns null then activity is not started`() { + configureSyncEnabled() + configureSignedOut() + whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(null) + val jsMessage = createJsMessage("sendToSyncSettings", "test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockGlobalActivityStarter).startIntent(mockContext, SyncActivityWithEmptyParams) + verify(mockContext, never()).startActivity(any()) + verifyNoInteractions(mockJsMessaging) + } + + @Test + fun `when sendToSetupSync method is called then activity is started`() { + configureSyncEnabled() + configureSignedOut() + val intent = Intent() + whenever(mockGlobalActivityStarter.startIntent(any(), any())).thenReturn(intent) + val jsMessage = createJsMessage("sendToSetupSync", "test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockGlobalActivityStarter).startIntent(mockContext, SyncActivityWithEmptyParams) + verify(mockContext).startActivity(intent) + verifyNoInteractions(mockJsMessaging) + } + + private fun configureSyncEnabled() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + } + + private fun configureSignedOut() { + whenever(mockDeviceSyncState.getAccountState()).thenReturn(DeviceSyncState.SyncAccountState.SignedOut) + } + + private fun verifyErrorResponse(params: JSONObject, expectedReason: String) { + assertFalse(params.getBoolean("ok")) + assertEquals(expectedReason, params.getString("reason")) + } + + private fun createJsMessage(method: String, id: String?): JsMessage { + return JsMessage( + context = "test", + featureName = "aiChat", + method = method, + id = id, + params = JSONObject(), + ) + } +} diff --git a/sync/sync-impl/build.gradle b/sync/sync-impl/build.gradle index 48475310ca4f..74a05fc10608 100644 --- a/sync/sync-impl/build.gradle +++ b/sync/sync-impl/build.gradle @@ -50,6 +50,8 @@ dependencies { implementation project(path: ':app-build-config-api') implementation project(path: ':privacy-config-api') implementation project(':statistics-api') + implementation project(':content-scope-scripts-api') + implementation project(':js-messaging-api') anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt index feecabf99ca3..2b62cb3f1837 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncService.kt @@ -116,9 +116,15 @@ interface SyncService { @Query("until") until: String, ): Call + @POST("$SYNC_PROD_ENVIRONMENT_URL/sync/token/rescope") + fun rescopeToken( + @Header("Authorization") token: String, + @Body request: TokenRescopeRequest, + ): Call + companion object { const val SYNC_PROD_ENVIRONMENT_URL = "https://sync.duckduckgo.com" - const val SYNC_DEV_ENVIRONMENT_URL = "https://dev-sync-use.duckduckgo.com" + const val SYNC_DEV_ENVIRONMENT_URL = "https://sync-staging.duckduckgo.com" } } @@ -188,6 +194,14 @@ data class ErrorResponse( val error: String, ) +data class TokenRescopeRequest( + val scope: String, +) + +data class TokenRescopeResponse( + val token: String, +) + @Suppress("ktlint:standard:class-naming") enum class API_CODE(val code: Int) { INVALID_LOGIN_CREDENTIALS(401), diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt index 5e620950296f..ac2bc33ddb08 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncServiceRemote.kt @@ -26,6 +26,7 @@ import logcat.LogPriority.INFO import logcat.LogPriority.VERBOSE import logcat.logcat import org.json.JSONObject +import retrofit2.HttpException import retrofit2.Response import javax.inject.* @@ -97,6 +98,15 @@ interface SyncApi { token: String, until: String, ): Result + + /** + * Obtain a new "scoped token" for the sync service + * A scoped token has a reduced range of capabilities, restricted to only the given scope + */ + fun rescopeToken( + token: String, + scope: String, + ): Result } @ContributesBinding(AppScope::class) @@ -400,6 +410,47 @@ class SyncServiceRemote @Inject constructor( } } + override fun rescopeToken( + token: String, + scope: String, + ): Result { + return runCatching { + val rescopeCall = syncService.rescopeToken("Bearer $token", TokenRescopeRequest(scope)) + val response = rescopeCall.execute() + + if (response.isSuccessful) { + val newToken = response.body()?.token.takeUnless { it.isNullOrEmpty() } + ?: return Result.Error(reason = "empty response") + Result.Success(newToken) + } else { + mapRescopeTokenError(response) + } + }.getOrElse { throwable -> + logcat(INFO) { "Sync-service: rescope token error ${throwable.localizedMessage}" } + val error = if (throwable is HttpException) { + Result.Error(code = throwable.code(), reason = "unexpected status code") + } else { + Result.Error(reason = "internal error") + } + error.removeKeysIfInvalid() + error + } + } + + private fun mapRescopeTokenError(response: Response): Result { + val errorBody = response.errorBody() + val hasErrorBody = runCatching { + errorBody != null && errorBody.string().isNotEmpty() + }.getOrDefault(false) + val error = if (hasErrorBody) { + Result.Error(code = response.code(), reason = "unexpected status code") + } else { + Result.Error(code = response.code(), reason = "empty response") + } + error.removeKeysIfInvalid() + return error + } + private fun onSuccess( response: Response, onSuccess: (T?) -> Result, diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandler.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandler.kt new file mode 100644 index 000000000000..8084360f9b0c --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandler.kt @@ -0,0 +1,150 @@ +/* + * 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.sync.impl.messaging + +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +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.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.api.DeviceSyncState.SyncAccountState.SignedIn +import com.duckduckgo.sync.impl.Result +import com.duckduckgo.sync.impl.SyncApi +import com.duckduckgo.sync.store.SyncStore +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class GetScopedSyncAuthTokenHandler @Inject constructor( + private val syncApi: SyncApi, + private val syncStore: SyncStore, + private val deviceSyncState: DeviceSyncState, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" } + + if (!deviceSyncState.isFeatureEnabled()) { + sendErrorResponse(jsMessaging, jsMessage, "sync unavailable") + return + } + + if (deviceSyncState.getAccountState() !is SignedIn) { + sendErrorResponse(jsMessaging, jsMessage, "sync off") + return + } + + val token = syncStore.token.takeUnless { it.isNullOrEmpty() } + ?: run { + sendErrorResponse(jsMessaging, jsMessage, "token unavailable") + return + } + + val jsonPayload = runCatching { + handleRescopeTokenResult(syncApi.rescopeToken(token, SCOPE)) + }.getOrElse { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: exception during rescope token: ${e.message}" } + createErrorPayload("internal error") + } + + sendResponse(jsMessaging, jsMessage, jsonPayload) + } + + private fun handleRescopeTokenResult(result: Result): JSONObject { + return when (result) { + is Result.Success -> { + logcat(LogPriority.INFO) { "DuckChat-Sync: rescope token succeeded" } + createSuccessPayload(result.data) + } + + is Result.Error -> { + logcat(LogPriority.ERROR) { "DuckChat-Sync: rescope token failed: code=${result.code}, reason=${result.reason}" } + createErrorPayload(result.reason) + } + } + } + + private fun createSuccessPayload(token: String): JSONObject { + return JSONObject().apply { + put("ok", true) + put( + "payload", + JSONObject().apply { + put("token", token) + }, + ) + } + } + + private fun createErrorPayload(reason: String): JSONObject { + return JSONObject().apply { + put("ok", false) + put("reason", reason) + } + } + + private fun sendErrorResponse( + jsMessaging: JsMessaging, + jsMessage: JsMessage, + error: String, + ) { + sendResponse(jsMessaging, jsMessage, createErrorPayload(error)) + } + + private fun sendResponse( + jsMessaging: JsMessaging, + jsMessage: JsMessage, + jsonPayload: JSONObject, + ) { + runCatching { + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)).also { + logcat { "DuckChat-Sync: responded to ${jsMessage.method} with payload" } + } + }.onFailure { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: failed to send response for ${jsMessage.method}: ${e.message}" } + } + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("getScopedSyncAuthToken") + } + + private companion object { + private const val SCOPE = "ai_chats" + private const val HOST_DUCK_AI = "duck.ai" + } +} diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandler.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandler.kt new file mode 100644 index 000000000000..4d45b48a9677 --- /dev/null +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandler.kt @@ -0,0 +1,93 @@ +/* + * 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.sync.impl.messaging + +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.contentscopescripts.api.ContentScopeJsMessageHandlersPlugin +import com.duckduckgo.di.scopes.AppScope +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.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.store.SyncStore +import com.squareup.anvil.annotations.ContributesMultibinding +import logcat.LogPriority +import logcat.logcat +import org.json.JSONObject +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class GetSyncStatusHandler @Inject constructor( + private val deviceSyncState: DeviceSyncState, + private val syncStore: SyncStore, +) : ContentScopeJsMessageHandlersPlugin { + override fun getJsMessageHandler(): JsMessageHandler = + object : JsMessageHandler { + override fun process( + jsMessage: JsMessage, + jsMessaging: JsMessaging, + jsMessageCallback: JsMessageCallback?, + ) { + if (jsMessage.id.isNullOrEmpty()) return + + logcat(LogPriority.WARN) { "DuckChat-Sync: ${jsMessage.method} called" } + + val jsonPayload = runCatching { + val syncAvailable = deviceSyncState.isFeatureEnabled() + val signedIn = syncStore.isSignedIn() + + val payload = JSONObject().apply { + put("syncAvailable", syncAvailable) + put("userId", if (signedIn) (syncStore.userId ?: JSONObject.NULL) else JSONObject.NULL) + put("deviceId", if (signedIn) (syncStore.deviceId ?: JSONObject.NULL) else JSONObject.NULL) + put("deviceName", if (signedIn) (syncStore.deviceName ?: JSONObject.NULL) else JSONObject.NULL) + put("deviceType", if (signedIn) "mobile" else JSONObject.NULL) + } + + JSONObject().apply { + put("ok", true) + put("payload", payload) + } + }.getOrElse { e -> + logcat(LogPriority.ERROR) { "DuckChat-Sync: exception getting sync status: ${e.message}" } + JSONObject().apply { + put("ok", false) + put("reason", "internal error") + } + } + + jsMessaging.onResponse(JsCallbackData(jsonPayload, featureName, jsMessage.method, jsMessage.id!!)).also { + logcat { "DuckChat-Sync: responded to ${jsMessage.method} with $jsonPayload" } + } + } + + override val allowedDomains: List = + listOf( + AppUrl.Url.HOST, + HOST_DUCK_AI, + ) + + override val featureName: String = "aiChat" + override val methods: List = listOf("getSyncStatus") + } + + private companion object { + private const val HOST_DUCK_AI = "duck.ai" + } +} diff --git a/sync/sync-impl/src/main/res/values/donottranslate.xml b/sync/sync-impl/src/main/res/values/donottranslate.xml index e4470a56ad44..84ad05c67191 100644 --- a/sync/sync-impl/src/main/res/values/donottranslate.xml +++ b/sync/sync-impl/src/main/res/values/donottranslate.xml @@ -20,7 +20,7 @@ Environment Settings Use Dev Environment? https://sync.duckduckgo.com - https://dev-sync-use.duckduckgo.com + https://sync-staging.duckduckgo.com Account Settings Device Settings Recovery code diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt index 6cb51f0cf87b..26212186d4f7 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/TestSyncFixtures.kt @@ -34,8 +34,10 @@ import com.duckduckgo.sync.impl.LoginResponse import com.duckduckgo.sync.impl.Logout import com.duckduckgo.sync.impl.Result import com.duckduckgo.sync.impl.Signup +import com.duckduckgo.sync.impl.TokenRescopeResponse import com.duckduckgo.sync.impl.encodeB64 import okhttp3.ResponseBody.Companion.toResponseBody +import org.json.JSONObject import retrofit2.Response import java.io.File @@ -187,6 +189,30 @@ object TestSyncFixtures { val connectDeviceKeysNotFoundError = Result.Error(code = keysNotFoundCode, reason = keysNotFoundErr) val connectDeviceKeysGoneError = Result.Error(code = keysGoneCode, reason = keysNotFoundErr) + const val scopedToken = "scoped-token-123" + const val untilTimestamp = "2024-01-01T00:00:00Z" + val tokenRescopeResponseBody = TokenRescopeResponse(token = scopedToken) + val rescopeTokenSuccessResponse: Response = Response.success(tokenRescopeResponseBody) + val rescopeTokenErrorResponse: Response = Response.error( + invalidCodeErr, + "{\"error\":\"$invalidMessageErr\"}".toResponseBody(), + ) + val rescopeTokenEmptyErrorResponse: Response = Response.error( + invalidCodeErr, + "".toResponseBody(), + ) + val rescopeTokenSuccess = Result.Success(scopedToken) + val rescopeTokenError = Result.Error(code = invalidCodeErr, reason = "unexpected status code") + val rescopeTokenEmptyError = Result.Error(code = invalidCodeErr, reason = "empty response") + + val deleteAiChatsSuccessResponse: Response = Response.success(JSONObject()) + val deleteAiChatsErrorResponse: Response = Response.error( + invalidCodeErr, + "{\"error\":\"$invalidMessageErr\"}".toResponseBody(), + ) + val deleteAiChatsSuccess = Result.Success(Unit) + val deleteAiChatsError = Result.Error(code = invalidCodeErr, reason = invalidMessageErr) + val firstSyncWithBookmarksAndFavorites = "{\"bookmarks\":{\"updates\":[{\"client_last_modified\":\"timestamp\"" + ",\"folder\":{\"children\":[\"bookmark1\"]},\"id\":\"favorites_root\",\"title\":\"Favorites\"},{\"client_last_modified\"" + ":\"timestamp\",\"id\":\"bookmark3\",\"page\":{\"url\":\"https://bookmark3.com\"},\"title\":\"Bookmark 3\"}" + diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncServiceRemoteTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncServiceRemoteTest.kt index d33ee63ef3bd..4133c8fdb584 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncServiceRemoteTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/SyncServiceRemoteTest.kt @@ -34,6 +34,10 @@ import com.duckduckgo.sync.TestSyncFixtures.deleteAccountError import com.duckduckgo.sync.TestSyncFixtures.deleteAccountInvalid import com.duckduckgo.sync.TestSyncFixtures.deleteAccountResponse import com.duckduckgo.sync.TestSyncFixtures.deleteAccountSuccess +import com.duckduckgo.sync.TestSyncFixtures.deleteAiChatsError +import com.duckduckgo.sync.TestSyncFixtures.deleteAiChatsErrorResponse +import com.duckduckgo.sync.TestSyncFixtures.deleteAiChatsSuccess +import com.duckduckgo.sync.TestSyncFixtures.deleteAiChatsSuccessResponse import com.duckduckgo.sync.TestSyncFixtures.deviceFactor import com.duckduckgo.sync.TestSyncFixtures.deviceId import com.duckduckgo.sync.TestSyncFixtures.deviceLogoutBody @@ -54,11 +58,18 @@ import com.duckduckgo.sync.TestSyncFixtures.loginSuccess import com.duckduckgo.sync.TestSyncFixtures.loginSuccessResponse import com.duckduckgo.sync.TestSyncFixtures.logoutError import com.duckduckgo.sync.TestSyncFixtures.logoutSuccess +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenEmptyError +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenEmptyErrorResponse +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenError +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenErrorResponse +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenSuccess +import com.duckduckgo.sync.TestSyncFixtures.rescopeTokenSuccessResponse import com.duckduckgo.sync.TestSyncFixtures.signUpRequest import com.duckduckgo.sync.TestSyncFixtures.signupFailDuplicatedUser import com.duckduckgo.sync.TestSyncFixtures.signupFailInvalid import com.duckduckgo.sync.TestSyncFixtures.signupSuccess import com.duckduckgo.sync.TestSyncFixtures.token +import com.duckduckgo.sync.TestSyncFixtures.untilTimestamp import com.duckduckgo.sync.TestSyncFixtures.userId import com.duckduckgo.sync.store.* import org.junit.Assert.assertEquals @@ -67,16 +78,17 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.* import retrofit2.Call +import retrofit2.HttpException @RunWith(AndroidJUnit4::class) class SyncServiceRemoteTest { private val syncService: SyncService = mock() private val syncStore: SyncStore = mock() + private val syncRemote = SyncServiceRemote(syncService, syncStore) @Test fun whenCreateAccountSucceedsThenReturnAccountCreatedSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.signup(signUpRequest)).thenReturn(call) whenever(call.execute()).thenReturn(signupSuccess) @@ -90,7 +102,6 @@ class SyncServiceRemoteTest { @Test fun whenCreateAccountIsInvalidThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.signup(signUpRequest)).thenReturn(call) whenever(call.execute()).thenReturn(signupFailInvalid) @@ -104,7 +115,6 @@ class SyncServiceRemoteTest { @Test fun whenCreateAccountDuplicateUserThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.signup(signUpRequest)).thenReturn(call) whenever(call.execute()).thenReturn(signupFailDuplicatedUser) @@ -118,7 +128,6 @@ class SyncServiceRemoteTest { @Test fun whenLogoutSucceedsThenReturnLogoutSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.logout(anyString(), eq(deviceLogoutBody))).thenReturn(call) whenever(call.execute()).thenReturn(deviceLogoutResponse) @@ -130,7 +139,6 @@ class SyncServiceRemoteTest { @Test fun whenLogoutIsInvalidThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.logout(anyString(), eq(deviceLogoutBody))).thenReturn(call) whenever(call.execute()).thenReturn(logoutError) @@ -143,7 +151,6 @@ class SyncServiceRemoteTest { @Test fun whenDeleteAccountSucceedsThenReturnDeleteAccountSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.deleteAccount(anyString())).thenReturn(call) whenever(call.execute()).thenReturn(deleteAccountResponse) @@ -155,7 +162,6 @@ class SyncServiceRemoteTest { @Test fun whenDeleteAccountIsInvalidThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.deleteAccount(anyString())).thenReturn(call) whenever(call.execute()).thenReturn(deleteAccountError) @@ -168,7 +174,6 @@ class SyncServiceRemoteTest { @Test fun whenLoginSucceedsThenReturnLoginSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.login(loginRequestBody)).thenReturn(call) whenever(call.execute()).thenReturn(loginSuccessResponse) @@ -180,7 +185,6 @@ class SyncServiceRemoteTest { @Test fun whenLoginIsInvalidThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.login(loginRequestBody)).thenReturn(call) whenever(call.execute()).thenReturn(loginFailedInvalidResponse) @@ -192,7 +196,6 @@ class SyncServiceRemoteTest { @Test fun whenGetDevicesSuccessThenResultSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.getDevices(anyString())).thenReturn(call) whenever(call.execute()).thenReturn(getDevicesBodySuccessResponse) @@ -204,7 +207,6 @@ class SyncServiceRemoteTest { @Test fun whenGetDevicesSuccessFailsThenResultError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.getDevices(anyString())).thenReturn(call) whenever(call.execute()).thenReturn(getDevicesBodyErrorResponse) @@ -216,7 +218,6 @@ class SyncServiceRemoteTest { @Test fun whenGetDevicesIsInvalidCodeThenResultError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.getDevices(anyString())).thenReturn(call) whenever(call.execute()).thenReturn(getDevicesBodyInvalidCodeResponse) @@ -229,7 +230,6 @@ class SyncServiceRemoteTest { @Test fun whenConnectSuccedsThenReturnSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.connect(anyString(), eq(connectBody))).thenReturn(call) whenever(call.execute()).thenReturn(connectResponse) @@ -241,7 +241,6 @@ class SyncServiceRemoteTest { @Test fun whenConnectFailsThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.connect(anyString(), eq(connectBody))).thenReturn(call) whenever(call.execute()).thenReturn(connectInvalid) @@ -253,7 +252,6 @@ class SyncServiceRemoteTest { @Test fun whenConnectDeviceSuccedsThenReturnSuccess() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.connectDevice(deviceId)).thenReturn(call) whenever(call.execute()).thenReturn(connectDeviceResponse) @@ -265,7 +263,6 @@ class SyncServiceRemoteTest { @Test fun whenConnectDeviceFailsThenReturnError() { - val syncRemote = SyncServiceRemote(syncService, syncStore) val call: Call = mock() whenever(syncService.connectDevice(deviceId)).thenReturn(call) whenever(call.execute()).thenReturn(connectDeviceErrorResponse) @@ -274,4 +271,117 @@ class SyncServiceRemoteTest { assertEquals(connectDeviceKeysNotFoundError, result) } + + @Test + fun whenDeleteAiChatsSucceedsThenReturnSuccess() { + val call: Call = mock() + whenever(syncService.deleteAiChats(anyString(), eq(untilTimestamp))).thenReturn(call) + whenever(call.execute()).thenReturn(deleteAiChatsSuccessResponse) + + val result = syncRemote.deleteAiChats(token, untilTimestamp) + + assertEquals(deleteAiChatsSuccess, result) + } + + @Test + fun whenDeleteAiChatsFailsThenReturnError() { + val call: Call = mock() + whenever(syncService.deleteAiChats(anyString(), eq(untilTimestamp))).thenReturn(call) + whenever(call.execute()).thenReturn(deleteAiChatsErrorResponse) + + val result = syncRemote.deleteAiChats(token, untilTimestamp) + + assertEquals(deleteAiChatsError, result) + } + + @Test + fun whenDeleteAiChatsThrowsExceptionThenReturnError() { + val call: Call = mock() + val exception = RuntimeException("Network error") + whenever(syncService.deleteAiChats(anyString(), eq(untilTimestamp))).thenReturn(call) + whenever(call.execute()).thenThrow(exception) + + val result = syncRemote.deleteAiChats(token, untilTimestamp) + + assertEquals(Result.Error(reason = "Network error"), result) + } + + @Test + fun whenRescopeTokenSucceedsThenReturnSuccess() { + val call: Call = mock() + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenReturn(rescopeTokenSuccessResponse) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(rescopeTokenSuccess, result) + } + + @Test + fun whenRescopeTokenFailsWithErrorBodyThenReturnUnexpectedStatusCode() { + val call: Call = mock() + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenReturn(rescopeTokenErrorResponse) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(rescopeTokenError, result) + } + + @Test + fun whenRescopeTokenFailsWithEmptyErrorBodyThenReturnEmptyResponse() { + val call: Call = mock() + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenReturn(rescopeTokenEmptyErrorResponse) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(rescopeTokenEmptyError, result) + } + + @Test + fun whenRescopeTokenReturnsEmptyTokenThenReturnEmptyResponse() { + val call: Call = mock() + val emptyTokenResponse = retrofit2.Response.success(TokenRescopeResponse(token = "")) + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenReturn(emptyTokenResponse) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(Result.Error(reason = "empty response"), result) + } + + @Test + fun whenRescopeTokenReturnsNullBodyThenReturnEmptyResponse() { + val call: Call = mock() + val nullBodyResponse = retrofit2.Response.success(null) + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenReturn(nullBodyResponse) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(Result.Error(reason = "empty response"), result) + } + + @Test + fun whenRescopeTokenThrowsHttpExceptionThenReturnUnexpectedStatusCode() { + val call: Call = mock() + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenThrow(HttpException(rescopeTokenErrorResponse)) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(rescopeTokenError, result) + } + + @Test + fun whenRescopeTokenThrowsNonHttpExceptionThenReturnInternalError() { + val call: Call = mock() + whenever(syncService.rescopeToken(anyString(), any())).thenReturn(call) + whenever(call.execute()).thenThrow(RuntimeException("Network error")) + + val result = syncRemote.rescopeToken(token, "aiChat") + + assertEquals(Result.Error(reason = "internal error"), result) + } } diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandlerTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandlerTest.kt new file mode 100644 index 000000000000..4772310517e3 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetScopedSyncAuthTokenHandlerTest.kt @@ -0,0 +1,248 @@ +/* + * 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.sync.impl.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.impl.Result +import com.duckduckgo.sync.impl.SyncApi +import com.duckduckgo.sync.store.SyncStore +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class GetScopedSyncAuthTokenHandlerTest { + + private val mockSyncApi: SyncApi = mock() + private val mockSyncStore: SyncStore = mock() + private val mockDeviceSyncState: DeviceSyncState = mock() + private val mockJsMessaging: JsMessaging = mock() + + val callbackDataCaptor = argumentCaptor() + + private lateinit var handler: GetScopedSyncAuthTokenHandler + + @Before + fun setUp() { + handler = GetScopedSyncAuthTokenHandler( + syncApi = mockSyncApi, + syncStore = mockSyncStore, + deviceSyncState = mockDeviceSyncState, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals("duck.ai", domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals(FEATURE_NAME, handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns getScopedSyncAuthToken`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(1, methods.size) + assertEquals(METHOD_NAME, methods[0]) + } + + @Test + fun `when id is null then no response is sent`() { + val jsMessage = createJsMessage(null) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when id is empty then no response is sent`() { + val jsMessage = createJsMessage("") + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when sync is disabled then error response is sent`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifyErrorResponse(response.params, "sync unavailable") + } + + @Test + fun `when sync is off then error response is sent`() { + configureSyncEnabled() + whenever(mockDeviceSyncState.getAccountState()).thenReturn(DeviceSyncState.SyncAccountState.SignedOut) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "sync off") + } + + @Test + fun `when token is null then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockSyncStore.token).thenReturn(null) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifyErrorResponse(response.params, "token unavailable") + } + + @Test + fun `when token is empty then error response is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockSyncStore.token).thenReturn("") + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "token unavailable") + } + + @Test + fun `when rescope token succeeds then success response with token is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockSyncStore.token).thenReturn(ORIGINAL_TOKEN) + whenever(mockSyncApi.rescopeToken(ORIGINAL_TOKEN, SCOPE)).thenReturn(Result.Success(SCOPED_TOKEN)) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals(TEST_MESSAGE_ID, response.id) + assertEquals(FEATURE_NAME, response.featureName) + assertEquals(METHOD_NAME, response.method) + verifySuccessResponse(response.params) + } + + @Test + fun `when rescope token fails then error response with reason is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockSyncStore.token).thenReturn(ORIGINAL_TOKEN) + whenever(mockSyncApi.rescopeToken(ORIGINAL_TOKEN, SCOPE)).thenReturn(Result.Error(code = ERROR_CODE, reason = ERROR_REASON)) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, ERROR_REASON) + } + + @Test + fun `when rescope token throws exception then internal error response is sent`() { + configureSyncEnabled() + configureSignedIn() + whenever(mockSyncStore.token).thenReturn(ORIGINAL_TOKEN) + whenever(mockSyncApi.rescopeToken(ORIGINAL_TOKEN, SCOPE)).thenThrow(RuntimeException("Network error")) + val jsMessage = createJsMessage(TEST_MESSAGE_ID) + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + verifyErrorResponse(response.params, "internal error") + } + + private fun configureSyncEnabled() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + } + + private fun configureSignedIn() { + whenever(mockDeviceSyncState.getAccountState()).thenReturn(DeviceSyncState.SyncAccountState.SignedIn(emptyList())) + } + + private fun verifyNoResponse() { + verifyNoInteractions(mockJsMessaging) + verifyNoInteractions(mockSyncApi) + verifyNoInteractions(mockSyncStore) + verifyNoInteractions(mockDeviceSyncState) + } + + private fun verifySuccessResponse(params: JSONObject) { + assertTrue(params.getBoolean("ok")) + val payload = params.getJSONObject("payload") + assertEquals(SCOPED_TOKEN, payload.getString("token")) + } + + private fun verifyErrorResponse(params: JSONObject, expectedReason: String) { + assertFalse(params.getBoolean("ok")) + assertEquals(expectedReason, params.getString("reason")) + } + + private fun createJsMessage(id: String?): JsMessage { + return JsMessage( + context = "test", + featureName = FEATURE_NAME, + method = METHOD_NAME, + id = id, + params = JSONObject(), + ) + } + + companion object { + private const val TEST_MESSAGE_ID = "test-id" + private const val FEATURE_NAME = "aiChat" + private const val METHOD_NAME = "getScopedSyncAuthToken" + private const val ORIGINAL_TOKEN = "original-token-123" + private const val SCOPED_TOKEN = "scoped-token-456" + private const val SCOPE = "ai_chats" + private const val ERROR_CODE = 401 + private const val ERROR_REASON = "Unauthorized" + } +} diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandlerTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandlerTest.kt new file mode 100644 index 000000000000..9d02da5cd8e1 --- /dev/null +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/messaging/GetSyncStatusHandlerTest.kt @@ -0,0 +1,205 @@ +/* + * 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.sync.impl.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.js.messaging.api.JsCallbackData +import com.duckduckgo.js.messaging.api.JsMessage +import com.duckduckgo.js.messaging.api.JsMessaging +import com.duckduckgo.sync.api.DeviceSyncState +import com.duckduckgo.sync.store.SyncStore +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class GetSyncStatusHandlerTest { + + private val mockDeviceSyncState: DeviceSyncState = mock() + private val mockSyncStore: SyncStore = mock() + private val mockJsMessaging: JsMessaging = mock() + + val callbackDataCaptor = argumentCaptor() + + private lateinit var handler: GetSyncStatusHandler + + @Before + fun setUp() { + handler = GetSyncStatusHandler( + deviceSyncState = mockDeviceSyncState, + syncStore = mockSyncStore, + ) + } + + @Test + fun `when checking allowed domains then returns duckduckgo dot com and duck dot ai`() { + val domains = handler.getJsMessageHandler().allowedDomains + assertEquals(2, domains.size) + assertEquals("duckduckgo.com", domains[0]) + assertEquals("duck.ai", domains[1]) + } + + @Test + fun `when checking feature name then returns aiChat`() { + assertEquals("aiChat", handler.getJsMessageHandler().featureName) + } + + @Test + fun `when checking methods then returns getSyncStatus`() { + val methods = handler.getJsMessageHandler().methods + assertEquals(1, methods.size) + assertEquals("getSyncStatus", methods[0]) + } + + @Test + fun `when id is null then no response is sent`() { + val jsMessage = createJsMessage(null) + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when id is empty then no response is sent`() { + val jsMessage = createJsMessage("") + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + verifyNoResponse() + } + + @Test + fun `when signed in and sync available then response includes user data`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + configureSignedIn() + val jsMessage = createJsMessage("test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + assertEquals("test-id", response.id) + assertEquals("aiChat", response.featureName) + assertEquals("getSyncStatus", response.method) + + val payload = response.params.getJSONObject("payload") + verifySyncAvailable(payload) + assertTrue(response.params.getBoolean("ok")) + } + + @Test + fun `when signed in and sync not available then response includes user data but syncAvailable is false`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + configureSignedIn() + val jsMessage = createJsMessage("test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + val payload = response.params.getJSONObject("payload") + assertFalse(payload.getBoolean("syncAvailable")) + assertEquals(SIGNED_IN_USER_ID, payload.getString("userId")) + assertEquals(SIGNED_IN_DEVICE_ID, payload.getString("deviceId")) + assertEquals(SIGNED_IN_DEVICE_NAME, payload.getString("deviceName")) + assertEquals(DEVICE_TYPE_MOBILE, payload.getString("deviceType")) + } + + @Test + fun `when not signed in and sync available then response has null user data`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(true) + whenever(mockSyncStore.isSignedIn()).thenReturn(false) + val jsMessage = createJsMessage("test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + val payload = response.params.getJSONObject("payload") + assertTrue(payload.getBoolean("syncAvailable")) + assertTrue(payload.isNull("userId")) + assertTrue(payload.isNull("deviceId")) + assertTrue(payload.isNull("deviceName")) + assertTrue(payload.isNull("deviceType")) + } + + @Test + fun `when not signed in and sync not available then response has null user data and syncAvailable is false`() { + whenever(mockDeviceSyncState.isFeatureEnabled()).thenReturn(false) + whenever(mockSyncStore.isSignedIn()).thenReturn(false) + val jsMessage = createJsMessage("test-id") + + handler.getJsMessageHandler().process(jsMessage, mockJsMessaging, null) + + verify(mockJsMessaging).onResponse(callbackDataCaptor.capture()) + val response = callbackDataCaptor.firstValue + val payload = response.params.getJSONObject("payload") + verifySyncUnavailable(payload) + } + + private fun configureSignedIn() { + whenever(mockSyncStore.isSignedIn()).thenReturn(true) + whenever(mockSyncStore.userId).thenReturn(SIGNED_IN_USER_ID) + whenever(mockSyncStore.deviceId).thenReturn(SIGNED_IN_DEVICE_ID) + whenever(mockSyncStore.deviceName).thenReturn(SIGNED_IN_DEVICE_NAME) + } + + private fun verifySyncAvailable(payload: JSONObject) { + assertTrue(payload.getBoolean("syncAvailable")) + assertEquals(SIGNED_IN_USER_ID, payload.getString("userId")) + assertEquals(SIGNED_IN_DEVICE_ID, payload.getString("deviceId")) + assertEquals(SIGNED_IN_DEVICE_NAME, payload.getString("deviceName")) + assertEquals(DEVICE_TYPE_MOBILE, payload.getString("deviceType")) + } + + private fun verifySyncUnavailable(payload: JSONObject) { + assertFalse(payload.getBoolean("syncAvailable")) + assertTrue(payload.isNull("userId")) + assertTrue(payload.isNull("deviceId")) + assertTrue(payload.isNull("deviceName")) + assertTrue(payload.isNull("deviceType")) + } + + private fun verifyNoResponse() { + verifyNoInteractions(mockJsMessaging) + verifyNoInteractions(mockDeviceSyncState) + verifyNoInteractions(mockSyncStore) + } + + private fun createJsMessage(id: String?): JsMessage { + return JsMessage( + context = "test", + featureName = "aiChat", + method = "getSyncStatus", + id = id, + params = JSONObject(), + ) + } + + companion object { + private const val SIGNED_IN_USER_ID = "user123" + private const val SIGNED_IN_DEVICE_ID = "device456" + private const val SIGNED_IN_DEVICE_NAME = "My Device" + private const val DEVICE_TYPE_MOBILE = "mobile" + } +}