Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ import com.duckduckgo.app.browser.tabs.TabManager.TabModel
import com.duckduckgo.app.browser.tabs.adapter.TabPagerAdapter
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.dispatchers.ExternalIntentProcessingState
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.app.feedback.ui.common.FeedbackActivity
import com.duckduckgo.app.fire.DataClearer
import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel
Expand Down Expand Up @@ -122,6 +121,7 @@ import com.duckduckgo.dataclearing.api.fire.FireDialogProvider
import com.duckduckgo.dataclearing.api.fire.FireDialogProvider.FireDialogOrigin.Browser
import com.duckduckgo.dataclearing.api.fire.FireDialogProvider.FireDialogOrigin.DuckAiContextualChat
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.duckchat.api.DuckAiFeatureState
import com.duckduckgo.duckchat.api.DuckChat
import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import com.duckduckgo.anvil.annotations.ContributeToActivityStarter
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.browser.databinding.ActivityDownloadsBinding
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.app.downloads.DownloadsViewModel.Command
import com.duckduckgo.app.downloads.DownloadsViewModel.Command.*
import com.duckduckgo.app.downloads.DownloadsViewModel.ViewState
Expand All @@ -44,6 +43,7 @@ import com.duckduckgo.common.ui.view.showKeyboard
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import com.duckduckgo.downloads.api.DownloadsFileActions
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.downloads.api.model.DownloadItem
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import android.content.Context
import com.duckduckgo.anvil.annotations.ContributesActivePlugin
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
import com.duckduckgo.navigation.api.GlobalActivityStarter
Expand Down
1 change: 1 addition & 0 deletions downloads/downloads-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ plugins {
apply from: "$rootProject.projectDir/gradle/android-library.gradle"

dependencies {
implementation project(':navigation-api')
implementation KotlinX.coroutines.core
implementation Google.android.material
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

package com.duckduckgo.app.downloads
package com.duckduckgo.downloads.api

import com.duckduckgo.navigation.api.GlobalActivityStarter.ActivityParams

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2026 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.history

import android.os.Environment
import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.downloads.api.DownloadsRepository
import com.duckduckgo.downloads.api.FileDownloadCallbackPlugin
import com.duckduckgo.downloads.api.model.DownloadItem
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import java.io.File
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import javax.inject.Inject

/**
* Writes a chat export to the public Downloads directory. Filename pattern
* `duck.ai_yyyy-MM-dd_HH-mm-ss.<txt|zip>` matches the cross-platform reference;
* collisions get a `-1`, `-2`, ... suffix before the extension.
*/
interface ChatExportWriter {
suspend fun write(payload: ExportPayload): File
}

sealed interface ExportPayload {
val content: String

data class Text(override val content: String) : ExportPayload

data class Zip(
override val content: String,
val images: List<Image>,
) : ExportPayload {
data class Image(val name: String, val bytes: ByteArray)
}
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealChatExportWriter @Inject constructor(
private val downloadsRepository: DownloadsRepository,
private val fileDownloadCallbackPlugins: PluginPoint<FileDownloadCallbackPlugin>,
) : ChatExportWriter {

private val clock: () -> LocalDateTime = { LocalDateTime.now(ZoneId.systemDefault()) }

override suspend fun write(payload: ExportPayload): File {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
.also { it.mkdirs() }
return when (payload) {
is ExportPayload.Text -> writeText(dir, payload)
is ExportPayload.Zip -> writeZip(dir, payload)
}
}

private suspend fun writeText(dir: File, payload: ExportPayload.Text): File {
val file = resolveAvailableFile(dir, baseName(), EXTENSION_TXT)
file.writeText(payload.content)
registerInDownloads(file)
return file
}

private suspend fun writeZip(dir: File, payload: ExportPayload.Zip): File {
val file = resolveAvailableFile(dir, baseName(), EXTENSION_ZIP)
ZipOutputStream(file.outputStream().buffered()).use { zip ->
zip.putNextEntry(ZipEntry("chat.txt"))
// Reference samples ship chat.txt with a UTF-8 BOM for Notepad-friendliness on Windows.
zip.write(BOM_UTF8)
zip.write(payload.content.toByteArray(Charsets.UTF_8))
zip.closeEntry()

payload.images.forEach { image ->
zip.putNextEntry(ZipEntry(image.name))
zip.write(image.bytes)
zip.closeEntry()
}
}
registerInDownloads(file)
return file
}

private suspend fun registerInDownloads(file: File) {
downloadsRepository.insert(
DownloadItem(
downloadId = 0L,
downloadStatus = DOWNLOAD_FINISHED,
fileName = file.name,
contentLength = file.length(),
createdAt = DatabaseDateFormatter.timestamp(),
filePath = file.absolutePath,
),
)
// Surfaces the "new download" dot on the browser menu, same path as a browser download.
fileDownloadCallbackPlugins.getPlugins().forEach { it.onFileDownloaded() }
}

private fun baseName(): String = "duck.ai_${clock().format(FILENAME_FORMATTER)}"

private fun resolveAvailableFile(dir: File, baseName: String, extension: String): File {
val initial = File(dir, "$baseName.$extension")
if (!initial.exists()) return initial
var count = 1
while (true) {
val candidate = File(dir, "$baseName-$count.$extension")
if (!candidate.exists()) return candidate
count++
}
}

private companion object {
const val DOWNLOAD_FINISHED = 1
const val EXTENSION_TXT = "txt"
const val EXTENSION_ZIP = "zip"
val FILENAME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")
val BOM_UTF8: ByteArray = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 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.history

import com.duckduckgo.duckchat.impl.models.ChatType
import com.duckduckgo.duckchat.impl.models.ModelDisplay
import org.json.JSONObject
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale

/**
* Formats a Duck.ai chat (FE-owned JSON blob) into the cross-platform plain-text shape.
* Pure — no I/O, no DI. Inject a [ZoneId] to keep tests deterministic across machine timezones.
*/
internal class ChatExporter(private val zoneId: ZoneId = ZoneId.systemDefault()) {

fun export(
rawJson: String,
chatType: ChatType = ChatType.Discussion,
fileRefs: List<String> = emptyList(),
modelDisplay: ModelDisplay? = null,
): ExportResult {
val json = JSONObject(rawJson)
val display = modelDisplay ?: rawIdFallback(json.optString("model"))
val turns = extractTurns(json.optJSONArray("messages"))

return when (chatType) {
ChatType.Discussion -> ExportResult.Text(renderDiscussion(display, turns))
ChatType.Voice -> ExportResult.Text(renderVoice(display, turns))
ChatType.ImageGeneration -> renderImage(display, turns, fileRefs)
}
}

private fun renderDiscussion(display: ModelDisplay, turns: List<Turn>): String =
buildContent(display, turns) { _, turn -> "${display.shortName}:\n${turn.assistantText}" }

private fun renderVoice(display: ModelDisplay, turns: List<Turn>): String =
buildContent(display, turns) { _, turn ->
if (turn.assistantText.isBlank()) "" else "Voice Chat:\n${turn.assistantText}"
}

private fun renderImage(
display: ModelDisplay,
turns: List<Turn>,
fileRefs: List<String>,
): ExportResult.Zip {
val consumed = mutableListOf<String>()
val text = buildContent(display, turns) { index, _ ->
val uuid = fileRefs.getOrNull(index)
if (uuid != null) {
consumed += uuid
"${display.shortName}:\n\n[Generated image: image-${consumed.size}.jpeg]"
} else {
"${display.shortName}:"
}
}
return ExportResult.Zip(text, consumed)
}

private fun buildContent(
display: ModelDisplay,
turns: List<Turn>,
assistantBlock: (Int, Turn) -> String,
): String = buildString {
appendLine(header(display))
appendLine()
append(SEPARATOR)
turns.forEachIndexed { index, turn ->
if (index > 0) {
appendLine()
appendLine()
appendLine(TURN_SEPARATOR)
} else {
appendLine()
}
appendLine()
appendLine("User prompt ${index + 1} of ${turns.size} - ${formatTimestamp(turn.createdAt)}:")
append(turn.userText)
val assistant = assistantBlock(index, turn)
if (assistant.isNotEmpty()) {
appendLine()
appendLine()
append(assistant)
}
}
}

private fun extractTurns(messages: org.json.JSONArray?): List<Turn> {
if (messages == null || messages.length() == 0) return emptyList()
val turns = mutableListOf<Turn>()
var i = 0
while (i < messages.length()) {
val msg = messages.getJSONObject(i)
if (msg.optString("role") == "user") {
val createdAt = msg.optString("createdAt")
val userText = msg.optString("content")
val nextIsAssistant = i + 1 < messages.length() &&
messages.getJSONObject(i + 1).optString("role") == "assistant"
val assistantText = if (nextIsAssistant) extractAssistantText(messages.getJSONObject(i + 1)) else ""
turns += Turn(createdAt, userText, assistantText)
i += if (nextIsAssistant) 2 else 1
} else {
i++
}
}
return turns
}

private fun extractAssistantText(assistantMsg: JSONObject): String {
val parts = assistantMsg.optJSONArray("parts")
if (parts == null || parts.length() == 0) return assistantMsg.optString("content")
val textParts = buildList {
for (i in 0 until parts.length()) {
val part = parts.getJSONObject(i)
if (part.optString("type") == "text") add(part.optString("text"))
}
}
return if (textParts.isNotEmpty()) textParts.joinToString(separator = "\n") else assistantMsg.optString("content")
}

private fun formatTimestamp(iso: String): String {
val instant = runCatching { Instant.parse(iso) }.getOrElse { return iso }
return TIMESTAMP_FORMATTER.withZone(zoneId).format(instant)
}

/** Used when the caller didn't supply a resolved [ModelDisplay] — renders the raw model id with no provider. */
private fun rawIdFallback(modelId: String): ModelDisplay = ModelDisplay(
fullName = modelId.takeIf { it.isNotBlank() },
shortName = modelId.takeIf { it.isNotBlank() } ?: "AI",
providerPossessive = null,
)

private fun header(display: ModelDisplay): String {
val using = when {
display.providerPossessive != null -> "using ${display.providerPossessive} ${display.fullName} Model"
display.fullName != null -> "using the ${display.fullName} Model"
else -> "using an AI Model"
}
return "This conversation was generated with Duck.ai (https://duck.ai) $using. " +
"AI chats may display inaccurate or offensive information " +
"(see https://duckduckgo.com/duckai/privacy-terms for more info)."
}

private data class Turn(
val createdAt: String,
val userText: String,
val assistantText: String,
)

private companion object {
const val SEPARATOR = "===================="
const val TURN_SEPARATOR = "--------------------"
val TIMESTAMP_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("M/d/yyyy, h:mm:ss a", Locale.US)
}
}

internal sealed interface ExportResult {
val content: String

data class Text(override val content: String) : ExportResult
data class Zip(override val content: String, val imageFileRefs: List<String>) : ExportResult
}
Loading
Loading