Skip to content

Commit 7d604c8

Browse files
committed
Wire per-row Download in chat history overflow menu
1 parent 13f65fe commit 7d604c8

17 files changed

Lines changed: 518 additions & 6 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import com.duckduckgo.app.browser.tabs.TabManager.TabModel
7575
import com.duckduckgo.app.browser.tabs.adapter.TabPagerAdapter
7676
import com.duckduckgo.app.di.AppCoroutineScope
7777
import com.duckduckgo.app.dispatchers.ExternalIntentProcessingState
78-
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
78+
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
7979
import com.duckduckgo.app.feedback.ui.common.FeedbackActivity
8080
import com.duckduckgo.app.fire.DataClearer
8181
import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel

app/src/main/java/com/duckduckgo/app/downloads/DownloadsActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import com.duckduckgo.anvil.annotations.ContributeToActivityStarter
3131
import com.duckduckgo.anvil.annotations.InjectWith
3232
import com.duckduckgo.app.browser.R
3333
import com.duckduckgo.app.browser.databinding.ActivityDownloadsBinding
34-
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
34+
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
3535
import com.duckduckgo.app.downloads.DownloadsViewModel.Command
3636
import com.duckduckgo.app.downloads.DownloadsViewModel.Command.*
3737
import com.duckduckgo.app.downloads.DownloadsViewModel.ViewState

app/src/main/java/com/duckduckgo/app/downloads/DownloadsNewTabShortcutPlugin.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import android.content.Context
2020
import com.duckduckgo.anvil.annotations.ContributesActivePlugin
2121
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
2222
import com.duckduckgo.app.browser.R
23-
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
23+
import com.duckduckgo.downloads.api.DownloadsScreens.DownloadsScreenNoParams
2424
import com.duckduckgo.di.scopes.AppScope
2525
import com.duckduckgo.feature.toggles.api.Toggle
2626
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue

downloads/downloads-api/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ plugins {
2222
apply from: "$rootProject.projectDir/gradle/android-library.gradle"
2323

2424
dependencies {
25+
implementation project(':navigation-api')
2526
implementation KotlinX.coroutines.core
2627
implementation Google.android.material
2728
}

app/src/main/java/com/duckduckgo/app/downloads/DownloadsScreens.kt renamed to downloads/downloads-api/src/main/java/com/duckduckgo/downloads/api/DownloadsScreens.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
package com.duckduckgo.app.downloads
17+
package com.duckduckgo.downloads.api
1818

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.history
18+
19+
import android.content.Context
20+
import android.media.MediaScannerConnection
21+
import android.os.Environment
22+
import com.duckduckgo.common.utils.formatters.time.DatabaseDateFormatter
23+
import com.duckduckgo.di.scopes.AppScope
24+
import com.duckduckgo.downloads.api.DownloadsRepository
25+
import com.duckduckgo.downloads.api.model.DownloadItem
26+
import com.squareup.anvil.annotations.ContributesBinding
27+
import dagger.SingleInstanceIn
28+
import java.io.File
29+
import javax.inject.Inject
30+
31+
/**
32+
* Writes a formatted chat export to the public Downloads directory and registers it in the app's
33+
* Downloads database so it appears in the in-app Downloads screen.
34+
*/
35+
interface ChatExportWriter {
36+
suspend fun write(text: String, displayTitle: String): File
37+
}
38+
39+
@SingleInstanceIn(AppScope::class)
40+
@ContributesBinding(AppScope::class)
41+
class RealChatExportWriter @Inject constructor(
42+
private val context: Context,
43+
private val downloadsRepository: DownloadsRepository,
44+
) : ChatExportWriter {
45+
46+
override suspend fun write(text: String, displayTitle: String): File {
47+
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).also { it.mkdirs() }
48+
val file = resolveAvailableFile(dir, sanitize(displayTitle))
49+
file.writeText(text)
50+
51+
downloadsRepository.insert(
52+
DownloadItem(
53+
downloadId = 0L,
54+
downloadStatus = DOWNLOAD_FINISHED,
55+
fileName = file.name,
56+
contentLength = file.length(),
57+
createdAt = DatabaseDateFormatter.timestamp(),
58+
filePath = file.absolutePath,
59+
),
60+
)
61+
62+
MediaScannerConnection.scanFile(context, arrayOf(file.absolutePath), arrayOf("text/plain"), null)
63+
return file
64+
}
65+
66+
private fun sanitize(title: String): String {
67+
val cleaned = title.trim().replace(UNSAFE_CHARS, "_")
68+
return cleaned.ifBlank { "chat" }
69+
}
70+
71+
/** Mirrors :downloads-impl FilenameExtractor.addCountSuffix — appends `-1`, `-2`, ... before the extension. */
72+
private fun resolveAvailableFile(dir: File, baseName: String): File {
73+
val initial = File(dir, "$baseName.$EXTENSION")
74+
if (!initial.exists()) return initial
75+
var count = 1
76+
while (true) {
77+
val candidate = File(dir, "$baseName-$count.$EXTENSION")
78+
if (!candidate.exists()) return candidate
79+
count++
80+
}
81+
}
82+
83+
private companion object {
84+
const val DOWNLOAD_FINISHED = 1 // mirrors com.duckduckgo.downloads.store.DownloadStatus.FINISHED
85+
const val EXTENSION = "txt"
86+
val UNSAFE_CHARS = Regex("[\\\\/:*?\"<>|\\r\\n\\t]")
87+
}
88+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.history
18+
19+
import org.json.JSONObject
20+
import java.time.Instant
21+
import java.time.ZoneId
22+
import java.time.format.DateTimeFormatter
23+
import java.util.Locale
24+
25+
/**
26+
* Formats a Duck.ai chat (FE-owned JSON blob) into the plain-text shape defined by FR-016f.
27+
* Pure — no I/O, no DI. Inject a [ZoneId] to keep tests deterministic across machine timezones.
28+
*/
29+
internal class ChatExporter(private val zoneId: ZoneId = ZoneId.systemDefault()) {
30+
31+
fun export(rawJson: String): String {
32+
val json = JSONObject(rawJson)
33+
val display = ModelDisplay.from(json.optString("model"))
34+
val turns = extractTurns(json.optJSONArray("messages"))
35+
36+
return buildString {
37+
appendLine(header(display))
38+
appendLine()
39+
append(SEPARATOR)
40+
turns.forEachIndexed { index, turn ->
41+
appendLine()
42+
appendLine()
43+
appendLine("User prompt ${index + 1} of ${turns.size} - ${formatTimestamp(turn.createdAt)}:")
44+
appendLine(turn.userText)
45+
appendLine()
46+
appendLine("${display.shortName}:")
47+
append(turn.assistantText)
48+
}
49+
}
50+
}
51+
52+
private fun extractTurns(messages: org.json.JSONArray?): List<Turn> {
53+
if (messages == null || messages.length() == 0) return emptyList()
54+
val turns = mutableListOf<Turn>()
55+
var i = 0
56+
while (i < messages.length()) {
57+
val msg = messages.getJSONObject(i)
58+
if (msg.optString("role") == "user") {
59+
val createdAt = msg.optString("createdAt")
60+
val userText = msg.optString("content")
61+
val nextIsAssistant = i + 1 < messages.length() &&
62+
messages.getJSONObject(i + 1).optString("role") == "assistant"
63+
val assistantText = if (nextIsAssistant) extractAssistantText(messages.getJSONObject(i + 1)) else ""
64+
turns += Turn(createdAt, userText, assistantText)
65+
i += if (nextIsAssistant) 2 else 1
66+
} else {
67+
i++
68+
}
69+
}
70+
return turns
71+
}
72+
73+
private fun extractAssistantText(assistantMsg: JSONObject): String {
74+
val parts = assistantMsg.optJSONArray("parts")
75+
if (parts == null || parts.length() == 0) return assistantMsg.optString("content")
76+
val textParts = buildList {
77+
for (i in 0 until parts.length()) {
78+
val part = parts.getJSONObject(i)
79+
if (part.optString("type") == "text") add(part.optString("text"))
80+
}
81+
}
82+
return if (textParts.isNotEmpty()) textParts.joinToString(separator = "\n") else assistantMsg.optString("content")
83+
}
84+
85+
private fun formatTimestamp(iso: String): String {
86+
val instant = runCatching { Instant.parse(iso) }.getOrElse { return iso }
87+
return TIMESTAMP_FORMATTER.withZone(zoneId).format(instant)
88+
}
89+
90+
private fun header(display: ModelDisplay): String {
91+
val using = when {
92+
display.providerPossessive != null -> "using ${display.providerPossessive} ${display.fullName} Model"
93+
display.fullName != null -> "using the ${display.fullName} Model"
94+
else -> "using an AI Model"
95+
}
96+
return "This conversation was generated with Duck.ai (https://duck.ai) $using. " +
97+
"AI chats may display inaccurate or offensive information " +
98+
"(see https://duckduckgo.com/duckai/privacy-terms for more info)."
99+
}
100+
101+
private data class Turn(
102+
val createdAt: String,
103+
val userText: String,
104+
val assistantText: String,
105+
)
106+
107+
private companion object {
108+
const val SEPARATOR = "===================="
109+
val TIMESTAMP_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("M/d/yyyy, h:mm:ss a", Locale.US)
110+
}
111+
}
112+
113+
internal data class ModelDisplay(
114+
val fullName: String?,
115+
val shortName: String,
116+
val providerPossessive: String?,
117+
) {
118+
companion object {
119+
fun from(modelId: String): ModelDisplay = TABLE[modelId] ?: ModelDisplay(
120+
fullName = modelId.takeIf { it.isNotBlank() },
121+
shortName = modelId.takeIf { it.isNotBlank() } ?: "AI",
122+
providerPossessive = null,
123+
)
124+
125+
private val TABLE: Map<String, ModelDisplay> = mapOf(
126+
"gpt-5-mini" to ModelDisplay("GPT-5 mini", "GPT-5 mini", "OpenAI's"),
127+
"gpt-4o" to ModelDisplay("GPT-4o", "GPT-4o", "OpenAI's"),
128+
"gpt-4o-mini" to ModelDisplay("GPT-4o mini", "GPT-4o mini", "OpenAI's"),
129+
"claude-3-5-sonnet-latest" to ModelDisplay("Claude 3.5 Sonnet", "Claude 3.5 Sonnet", "Anthropic's"),
130+
"meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" to ModelDisplay("Llama 3.1 70B", "Llama 3.1 70B", "Meta's"),
131+
"mistralai/Mixtral-8x7B-Instruct-v0.1" to ModelDisplay("Mixtral 8x7B", "Mixtral 8x7B", "Mistral's"),
132+
)
133+
}
134+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryFragment.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ import com.duckduckgo.common.utils.extensions.hideKeyboard
3939
import com.duckduckgo.dataclearing.api.fire.FireDialog
4040
import com.duckduckgo.dataclearing.api.fire.FireDialogProvider
4141
import com.duckduckgo.di.scopes.FragmentScope
42+
import com.duckduckgo.downloads.api.DownloadsScreens
4243
import com.duckduckgo.duckchat.impl.R
4344
import com.duckduckgo.duckchat.impl.databinding.FragmentChatHistoryBinding
45+
import com.duckduckgo.navigation.api.GlobalActivityStarter
4446
import com.google.android.material.snackbar.Snackbar
4547
import kotlinx.coroutines.flow.launchIn
4648
import kotlinx.coroutines.flow.onEach
@@ -57,6 +59,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
5759
@Inject
5860
lateinit var fireDialogProvider: FireDialogProvider
5961

62+
@Inject
63+
lateinit var globalActivityStarter: GlobalActivityStarter
64+
6065
private val binding: FragmentChatHistoryBinding by viewBinding()
6166
private val viewModel: ChatHistoryViewModel by lazy {
6267
ViewModelProvider(this, viewModelFactory)[ChatHistoryViewModel::class.java]
@@ -135,6 +140,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
135140
private fun onNavigationEvent(event: ChatHistoryViewModel.NavigationEvent) {
136141
when (event) {
137142
is ChatHistoryViewModel.NavigationEvent.OpenRename -> openRenameScreen(event.chatId, event.currentTitle)
143+
is ChatHistoryViewModel.NavigationEvent.ShowDownloadComplete -> showDownloadCompleteSnackbar()
144+
ChatHistoryViewModel.NavigationEvent.ShowExportError ->
145+
Snackbar.make(binding.root, R.string.duck_ai_chat_history_download_error, Snackbar.LENGTH_SHORT).show()
138146
}
139147
}
140148

@@ -157,6 +165,14 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
157165
.show()
158166
}
159167

168+
private fun showDownloadCompleteSnackbar() {
169+
Snackbar.make(binding.root, R.string.duck_ai_chat_history_download_complete, Snackbar.LENGTH_LONG)
170+
.setAction(R.string.duck_ai_chat_history_download_view) {
171+
globalActivityStarter.start(requireContext(), DownloadsScreens.DownloadsScreenNoParams)
172+
}
173+
.show()
174+
}
175+
160176
private fun openRenameScreen(chatId: String, currentTitle: String) {
161177
parentFragmentManager.beginTransaction()
162178
.replace(R.id.chatHistoryFragmentContainer, RenameChatFragment.newInstance(chatId, currentTitle))
@@ -318,7 +334,9 @@ class ChatHistoryFragment : DuckDuckGoFragment(R.layout.fragment_chat_history) {
318334
popup.onMenuItemClicked(view.findViewById(R.id.rename)) {
319335
viewModel.onRenameRequested(item.chatId, item.displayTitle)
320336
}
321-
popup.onMenuItemClicked(view.findViewById(R.id.download)) { showComingSoonSnackbar() }
337+
popup.onMenuItemClicked(view.findViewById(R.id.download)) {
338+
viewModel.onDownloadRequested(item.chatId, item.displayTitle)
339+
}
322340
popup.onMenuItemClicked(view.findViewById(R.id.delete)) { viewModel.onDeleteSingleChat(item.chatId) }
323341
popup.show(binding.root, anchor)
324342
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryRepository.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import dagger.SingleInstanceIn
2727
import kotlinx.coroutines.flow.Flow
2828
import kotlinx.coroutines.flow.map
2929
import kotlinx.coroutines.withContext
30+
import java.io.File
3031
import java.time.Instant
3132
import javax.inject.Inject
3233

@@ -37,6 +38,13 @@ interface ChatHistoryRepository {
3738
suspend fun deleteAllChats()
3839
suspend fun renameChat(chatId: String, newTitle: String)
3940
suspend fun setPinned(chatId: String, pinned: Boolean)
41+
42+
/**
43+
* Formats [chatId] per FR-016f and writes it as a `.txt` file to the public Downloads
44+
* directory, registering it in the in-app Downloads list. Returns the resulting file.
45+
* Throws if the chat is missing or the write fails.
46+
*/
47+
suspend fun exportChat(chatId: String, displayTitle: String): File
4048
}
4149

4250
@ContributesBinding(AppScope::class)
@@ -45,8 +53,11 @@ class RealChatHistoryRepository @Inject constructor(
4553
private val chatStore: DuckAiChatStore,
4654
private val dispatchers: DispatcherProvider,
4755
private val context: Context,
56+
private val chatExportWriter: ChatExportWriter,
4857
) : ChatHistoryRepository {
4958

59+
private val chatExporter = ChatExporter()
60+
5061
private val fallbackTitle: String by lazy { context.getString(R.string.duck_ai_chat_history_untitled) }
5162

5263
override fun observeChats(): Flow<List<ChatHistoryItem>> =
@@ -69,6 +80,14 @@ class RealChatHistoryRepository @Inject constructor(
6980
withContext(dispatchers.io()) { chatStore.setPinned(chatId, pinned) }
7081
}
7182

83+
override suspend fun exportChat(chatId: String, displayTitle: String): File =
84+
withContext(dispatchers.io()) {
85+
val rawJson = chatStore.getChatContent(chatId)
86+
?: throw IllegalStateException("Chat $chatId not found")
87+
val text = chatExporter.export(rawJson)
88+
chatExportWriter.write(text, displayTitle)
89+
}
90+
7291
private fun toChatHistoryItem(chat: DuckAiChat): ChatHistoryItem = ChatHistoryItem(
7392
chatId = chat.chatId,
7493
displayTitle = chat.title.takeIf { it.isNotBlank() && it != UPSTREAM_UNTITLED } ?: fallbackTitle,

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/history/ChatHistoryViewModel.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ class ChatHistoryViewModel @Inject constructor(
164164
appScope.launch { chatHistoryRepository.setPinned(chatId, restorePinned) }
165165
}
166166

167+
fun onDownloadRequested(chatId: String, displayTitle: String) {
168+
viewModelScope.launch {
169+
runCatching { chatHistoryRepository.exportChat(chatId, displayTitle) }
170+
.onSuccess { file -> navigationChannel.trySend(NavigationEvent.ShowDownloadComplete(file.name)) }
171+
.onFailure { navigationChannel.trySend(NavigationEvent.ShowExportError) }
172+
}
173+
}
174+
167175
private fun dispatchSelectedClear(chatIds: Set<String>) {
168176
if (chatIds.isEmpty()) return
169177
val urls = chatIds.mapTo(mutableSetOf()) { duckChat.buildChatUrl(it) }
@@ -283,6 +291,8 @@ class ChatHistoryViewModel @Inject constructor(
283291

284292
sealed interface NavigationEvent {
285293
data class OpenRename(val chatId: String, val currentTitle: String) : NavigationEvent
294+
data class ShowDownloadComplete(val fileName: String) : NavigationEvent
295+
data object ShowExportError : NavigationEvent
286296
}
287297

288298
sealed interface MessageEvent {

0 commit comments

Comments
 (0)