Skip to content

Wire per-row Download in chat history overflow menu#8610

Merged
GerardPaligot merged 14 commits into
developfrom
feature/gerard/chat-history-download
May 27, 2026
Merged

Wire per-row Download in chat history overflow menu#8610
GerardPaligot merged 14 commits into
developfrom
feature/gerard/chat-history-download

Conversation

@GerardPaligot

@GerardPaligot GerardPaligot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Task/Issue URL: https://app.asana.com/1/137249556945/task/1214820120386826?focus=true

Description

Wires the Download action on the Duck.ai chat history screen so a chat is exported as a plain-text .txt file saved to the device's Downloads folder and registered with the in-app Downloads screen. A snackbar confirms the save with the resulting filename and offers a Show action that opens the Downloads screen.

Steps to test this PR

Note

Prerequisites:

  • Install Internal Debug.
  • In Settings → Developer Settings → Feature Flags, confirm duckAiChatHistory (self, historyScreen) is ON and duckChat → useNativeStorageChatData is ON.
  • Create at least one Duck.ai chat with a few back-and-forth turns.

Happy path

  • Open the Chats screen → tap the 3-dot on any row → tap Download → confirm a snackbar appears reading "Download complete for <filename>.txt" with a Show action.
  • Tap Show → confirm the in-app Downloads screen opens with the new .txt file listed.
  • Open the saved file from Downloads (or via a file manager in the public Downloads folder) → confirm it contains a Duck.ai/model header, a separator line, and each turn in order (user prompt + model response).

Guards

  • Trigger Download twice on the same chat → confirm the second save lands as &lt;title&gt;-1.txt, not an overwrite.
  • Download a chat whose title contains slashes, colons, or other unsafe characters → confirm the filename sanitises them and the snackbar shows the sanitised name.
  • Trigger Download from inside select mode → confirm the export still runs and the snackbar appears.

UI changes

Before After
!(Upload before screenshot) (Upload after screenshot)

Note

Medium Risk
Writes user chat content and images to external storage and decodes native file refs; mistakes could affect privacy or corrupt exports, but scope is localized to Duck.ai history/download flows.

Overview
Implements Download on Duck.ai chat history rows: chats export to the public Downloads folder as cross-platform plain text (or a zip with chat.txt and images for image-generation chats), with timestamped duck.ai_* filenames and -1, -2 collision suffixes. Exports register in the in-app Downloads list and trigger the same “new download” callbacks as browser downloads.

The history UI shows a completion snackbar with Show to open Downloads; failures show an error snackbar. DownloadsScreens moves from the app module into downloads-api (with navigation-api), and call sites update imports accordingly.

DuckAiChatStore gains getChatContent and readFileRef (base64 / data-URL decoding, path traversal checks) so exports can read stored JSON and image blobs. A small layout tweak adjusts the row overflow menu hit target.

Reviewed by Cursor Bugbot for commit d02b9ed. Bugbot is set up for automated code reviews on this repo. Configure here.

GerardPaligot commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-pin branch from 13f65fe to 7814890 Compare May 19, 2026 09:21
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch from c9c0069 to 3ade15f Compare May 19, 2026 09:21
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-pin branch from 7814890 to 8ccddb3 Compare May 19, 2026 09:38
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch 3 times, most recently from c1b6b95 to d95ddfc Compare May 19, 2026 12:38
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-pin branch 2 times, most recently from 0bdc467 to f77064b Compare May 19, 2026 21:51
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch 2 times, most recently from 44f76c2 to c1a8944 Compare May 20, 2026 08:25
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-pin branch from f77064b to 6530893 Compare May 20, 2026 08:25
@GerardPaligot GerardPaligot changed the base branch from feature/gerard/chat-history-pin to graphite-base/8610 May 20, 2026 09:31
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch from c1a8944 to 160544c Compare May 20, 2026 09:33
@GerardPaligot GerardPaligot changed the base branch from graphite-base/8610 to feature/gerard/chat-history-rename May 20, 2026 09:33
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch from 160544c to 50b6ef6 Compare May 20, 2026 14:27
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-rename branch from 65337eb to c62b930 Compare May 20, 2026 14:27
Base automatically changed from feature/gerard/chat-history-rename to develop May 20, 2026 14:41
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch 4 times, most recently from 38ec1f3 to 19959f6 Compare May 26, 2026 08:45
@GerardPaligot GerardPaligot marked this pull request as ready for review May 26, 2026 08:46

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 19959f6. Configure here.

@malmstein malmstein self-assigned this May 26, 2026

@malmstein malmstein left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smoke-tested on device. the per-row Download from the chat history overflow works as expected, and the new exporter + writer split looks clean and well-covered by tests. one observation worth flagging though: the touch feedback on the overflow trigger (the three-dot icon on each chat history row) is way too big — a large rounded ripple that extends well beyond the icon's touch target. screenshot to follow as a separate PR conversation comment. might be pre-existing rather than introduced here, but worth tightening while we're in this area.

Surfaces "Download complete for <filename>" with a Show action that
opens the Downloads screen, matching the design. The ViewModel already
emits the filename via ShowDownloadComplete — only the Fragment copy
and string format needed updating.

Also removes a duplicate setPinned override accidentally added to
RecordingRenameRepository, which was breaking compileDebugUnitTestKotlin.
- spotlessApply on three :app files to keep DownloadsScreenNoParams
  alphabetically after AppScope / DI imports.
- Add an instruction attribute to duck_ai_chat_history_download_complete
  so the MissingInstruction lint rule passes for the %1$s filename arg.
- Drop the now-stale setPinned override in RecordingRenameRepository;
  setPinned is no longer on ChatHistoryRepository after the rebase.
Brings Android's chat-history export in line with the macOS/Windows
reference for every chat type. ChatExporter branches by ChatType and
returns a sealed ExportResult so the writer can produce either a `.txt`
(discussion/voice) or a `.zip` (image-generation, bundling chat.txt
with a UTF-8 BOM and the resolved image bytes).

Filename pattern switches from the sanitized chat title to the
cross-platform `duck.ai_yyyy-MM-dd_HH-mm-ss.<txt|zip>` shape. Turns are
now separated with the reference's `--------------------` divider.
Voice turns with no model response omit the assistant block entirely.

DuckAiChatStore gains openFileRef(uuid) so the impl module can stream
image bytes for the zip without leaking the chat-files dir; the same
path-traversal guard used by deleteChat applies. Positional fileRef ↔
turn association is a documented known assumption pending confirmation
on the FE message schema for image-generation chats.
The on-disk file at <chat-files-dir>/<uuid> is a JSON envelope written
by the FE bridge via writeText(params.toString()), with the image bytes
base64-encoded inside a "data" field — not raw image bytes. Reading the
file as a stream gave back JSON, which is why the image-N.jpeg entries
exported for image-generation chats were unreadable.

Replace openFileRef(uuid): InputStream? with
readFileRef(uuid): FileRefContent? which parses the envelope, decodes
the base64 data, and exposes the FE-provided fileName + mimeType. Tests
cover plain base64, the data:<mime>;base64,<payload> URL prefix path,
missing data field, malformed JSON, and the path-traversal guard.

Use java.util.Base64 instead of android.util.Base64 — it's available
from Java 8 / API 26, which matches the project's minSdk, and works in
pure-JVM unit tests without a Robolectric runner.
Fire FileDownloadCallbackPlugin from the chat-export writer so the
browser-menu badge (DownloadBadgePlugin) and any other registered
plugins react the same way they do for browser-driven downloads.
Without this, a freshly exported chat sat in the Downloads list with
no indicator that a new file had arrived.

Also drops the speculative MediaScannerConnection.scanFile call: no
other code in the project uses it (browser downloads delegate to the
system DownloadManager, which notifies MediaScanner internally), and
the public-Downloads folder is browseable by file managers without it.
Removing it lets us drop the unused Context dependency on the writer
and the unused mimeType field on ExportPayload.Text.
Replace the list-scan lookup with the DAO's direct getById path —
one Room query instead of fetching and parsing every chat just to
pick one out by id.

getChatContent stays in place. DuckAiChat is a deliberately reduced
view of the FE-owned JSON ("The raw message tree is not exposed —
consumers get precomputed classification flags instead"), so the
exporter still needs the raw-blob escape hatch to walk messages and
render turn-by-turn output.
Removes the ~30-line model-id → display-string map that ChatExporter
used to source provider attributions from. The exporter now takes a
ModelDisplay? built by the caller; null falls back to rendering the
raw model id ("using the gpt-5.2 Model") so unknown models still
produce a valid export instead of a broken header.

- ChatExporter.export() gains a modelDisplay parameter.
- ChatHistoryRepository.exportChat() threads the resolved display
  through to the exporter and now exposes chat.model on
  ChatHistoryItem so the ViewModel can do the lookup.
- ModelDisplay moves to the models package next to AIChatModel and
  ModelProvider. New helpers — ModelProvider.possessive and
  AIChatModel.toModelDisplay() — bridge the live /duckchat/v1/models
  shape into the export-header strings.

The wire-up to DuckAiModelManager comes in the next commit; this one
passes null from the ViewModel as a placeholder.

Decision: https://app.asana.com/1/137249556945/task/1215062212938438
…odel

onDownloadRequested now snapshot-reads the /duckchat/v1/models cache
via DuckAiModelManager.modelState and threads the resulting
ModelDisplay through to ChatHistoryRepository.exportChat. The repo
stays a plain data-layer method — no model-manager dependency, no
hidden network side-effect inside the export path.

The lookup chain is:
  ChatHistoryItem.model (from this row's DuckAiChat)
    -> modelState.models.firstOrNull { it.id == model }
    -> aiChatModel.toModelDisplay()
    -> "using OpenAI's GPT-5 mini Model" in the export header.

Cache miss (cold start, fetch error, or model id unknown to the API)
falls back to ModelDisplay null, which the exporter renders as
"using the <raw-id> Model". The header stays valid; only the provider
attribution is dropped.

New tests cover the happy path (manager has the model -> repo
receives the full display) and the cache-miss path (repo receives
null and the export still completes).
DuckAiModelManager only fetches /duckchat/v1/models when entitlements
change, which can leave the cache stale for users who haven't toggled
their plan recently. Without a refresh, onDownloadRequested may read
an empty model list and fall back to the raw-id header even though a
fresh fetch would have populated the labels.

Kick off fetchModels() from the ViewModel's init block on
viewModelScope so it starts the moment the screen opens. The call is
fire-and-forget — the manager already swallows network failures, and
onDownloadRequested still degrades gracefully if the user taps
Download before the fetch completes.

Test asserts fetchModels is invoked exactly once on construction.
Pass over comments added across this branch:
- Drop references to specs/ files (research.md R-16, export-example.txt,
  chat-example.json); the specs directory is gitignored so the pointers
  would dangle once merged.
- Condense verbose KDoc on ModelDisplay, ChatExporter, ChatExportWriter,
  ChatHistoryRepository.exportChat, FileRefContent, readFileRef — same
  contract, fewer words.
- Drop test-block narration that restated the assertions it preceded;
  the assertion messages already carry that text.

No behavior change; tests, spotless, and lint stay green.
Last caller was the download row, which now invokes
viewModel.onDownloadRequested(...) directly. The matching string
duck_ai_chat_history_coming_soon is left intact — menu_chat_history_default.xml
still references it as the title of two placeholder menu items.
@GerardPaligot GerardPaligot force-pushed the feature/gerard/chat-history-download branch from 2a63418 to d02b9ed Compare May 27, 2026 08:10
@GerardPaligot GerardPaligot merged commit 8cb01cb into develop May 27, 2026
13 checks passed
@GerardPaligot GerardPaligot deleted the feature/gerard/chat-history-download branch May 27, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants