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
67 changes: 63 additions & 4 deletions .devbook/arc42/06-runtime-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ when the service answers.

- **One outbox, many kinds.** Each row is `id, kind, payload, attempts,
last_error, created_at`. The `kind` picks the sender; `capture` is the first,
and later kinds (an attachment upload that must go ahead of its capture, a task
pushed to its own endpoint) join the same queue and the same order.
and later kinds join the same queue and the same order: `task`, pushed to its
own endpoint, and `talk-note`, one entry that is several requests — see
**Talk Note Upload**.
- **Oldest first, head of line.** A transient failure (no network, a timeout, a
5xx, or a 401 from a service that restarted with a new signing key) stops the
flush, so nothing overtakes it. It is retried after 2s, 4s, 8s, 16s. The fifth
Expand Down Expand Up @@ -144,8 +145,8 @@ related: [".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-besid
A capture's files travel beside it rather than inside it (local ADR 0014). The
phone uploads each file first, under an id it mints itself, and posts the
capture that names them after; the capture document carries only their
metadata. The service side is built; the phone's upload and the desktop's
intake are later slices.
metadata. The service side and the phone's upload are built — the phone's half
is the talk note, below; the desktop's intake is a later slice.

- **Upload, then capture.** `PUT /api/sync/attachments/{id}` carries the bytes,
their declared `Content-Type` and their SHA-256 in `X-Attachment-Sha256`. The
Expand Down Expand Up @@ -202,6 +203,64 @@ sequenceDiagram
Sync-->>-Desktop: 200
```

## Talk Note Upload

```meta
related: [".devbook/domain/capture/features.md#talk-note", ".devbook/arc42/06-runtime-view.md#capture-attachments", ".devbook/arc42/06-runtime-view.md#mobile-capture-and-sync"]
```

A talk note is the phone's half of **Capture Attachments**: one outbox entry of
kind `talk-note` whose payload is the capture — attachment metadata included —
and the ids of the files already uploaded. The bytes wait beside the database
in `talk-notes/outbox/`, since an outbox row holds JSON.

- **Prepared once, at Send.** Each picture is downscaled (1600 px longest edge,
JPEG, EXIF reduced to the orientation) unless the note keeps originals; the
size and SHA-256 are taken from the bytes that will actually go.
- **Uploads, checkpointed.** An attempt uploads each file not yet listed as
uploaded, then posts the capture. Every upload that lands is written back
into the entry before the next starts (`IStagedOutboxKind`), and hands the
entry its attempts back — so a retry resumes at the first file still to go,
and each file has the whole backoff budget of its own.
- **One refusal refuses the note.** A 409, 413 or 415 on a file sets the entry
aside naming the file; the capture is never posted without it.
- **Released on delivery.** The files are deleted from the phone once the
capture is taken (201, or 200 for an id the service already holds).
- **Status from the outbox.** *synced* once the entry is gone, *uploading i of
n* while an attempt is sending its files, *waiting* otherwise, and *waiting —
tap to retry* once parked.

```mermaid
sequenceDiagram
actor ME
participant App as Phone App
participant Outbox as SQLite Outbox
participant Files as talk-notes/outbox
participant Sync as Sync Service

ME->>App: Send to Inbox (body, speaker, tags, files)
App->>Files: Downscale pictures, move files; size + sha256
App->>Outbox: INSERT (id v7, kind=talk-note, capture + uploaded=[])
App-->>ME: Inbox row marked waiting; note says "waiting"

loop Each file not yet uploaded
Outbox->>+Sync: PUT /api/sync/attachments/{id} (X-Attachment-Sha256)
alt 201, or 200 for the same bytes
Sync-->>Outbox: StoredAttachment
Outbox->>Outbox: Checkpoint: uploaded += id, attempts = 0
else No network, timeout, 5xx or 401
Sync-->>Outbox: Transient — attempts+1, back off, stop the flush
else 409, 413 or 415
Sync-->>-Outbox: Refused — set aside, naming the file
end
end

Outbox->>+Sync: POST /api/sync/inbox (same id, attachments: metadata)
Sync-->>-Outbox: 201 InboxItem
Outbox->>Outbox: DELETE entry
Outbox->>Files: Delete the note's files
App-->>ME: "synced"

## Mobile My Day and Task Push

```meta
Expand Down
32 changes: 32 additions & 0 deletions .devbook/domain/capture/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,38 @@ down, on the refresh button and on returning to the app. When the service
cannot answer, the last list the phone saw stays on screen with a line saying
why it is not newer.

### Talk note

```meta
type: sub-feature
related: [".devbook/arc42/06-runtime-view.md#talk-note-upload", ".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-beside-the-replica.md"]
```

A note taken during a conference talk — what was said, the slide photo, the
handout — that lands in the desktop Inbox as one ordinary capture. It is called
a talk note, never a session note: Sessions is a bounded context of its own.

The Note tab holds a title, a Markdown body that dictation appends to, the
speaker as one `@name`, tags, and the files attached to it: photos taken with
the camera, pictures and files chosen from the phone, several to a note. Each
file shows its name, type and size. A file cloud sync would not take — over the
per-file limit, or not a picture, PDF, text or Office file — stays on the strip
with the reason written under it, and the note is not sent until it is taken
off. The note being written survives a switch to another tab and the app going
to the background.

Pictures are made smaller before they leave: at most 1600 pixels on the longest
side, as JPEG, with the location, camera and time details removed and only the
way-up kept. A note can keep its pictures as taken instead. Files go as they
are.

Sending the note keeps it on the phone first, like any capture, and it appears
in the Inbox list marked waiting. Each file is uploaded before the capture that
names it; a file that already went is never sent again, and a failed one is
retried on its own. The note says where it stands — waiting, uploading 2 of 3,
synced. A note sent without a title is named for the first line of its body,
or "Photo · <date>" when a picture is attached, or else the first file's name.

### Share-sheet and shortcuts

```meta
Expand Down
22 changes: 22 additions & 0 deletions .devbook/tech/mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ JSON-backed on-device storage for captures made while offline.
(`.devbook/arc42/adr/0003-sqlite-is-the-canonical-local-task-store.md`), so adopting it
here would mean sharing an adapter rather than introducing a new dependency.

## SkiaSharp

```meta
status: adopted
type: library
version: "4.152.1"
related: [".devbook/domain/capture/features.md#talk-note", ".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-beside-the-replica.md"]
alternatives: ["ImageSharp", "Android Bitmap plus a browser canvas"]
```

The image codec the phone downscales a talk note's pictures with, before they
enter the outbox.

- **Used for** — decoding a slide photo, resizing it to a 1600 px longest edge
and re-encoding it as JPEG; the EXIF orientation is carried over by hand and
everything else in EXIF is dropped.
- **Why** — MIT-licensed, with the Android and Windows natives in the package,
so the MAUI head, the browser harness and the unit tests run the one codec
and the downscaler has one implementation a test can hold. ImageSharp's split
licence and a per-platform pair (Android `Bitmap` beside a browser canvas)
were the alternatives.

## Android SDK Build Tools

```meta
Expand Down
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@
# instead of fixing a generator's phantom diff. The same goes for each knowledge
# folder's authored `_reading-order.json`, which no generator writes.
**/_archify/index.json text eol=lf

# Test fixtures are byte-exact inputs: a PDF's xref offsets and a JPEG's EXIF
# would break under line-ending conversion.
tests/**/Fixtures/** binary
6 changes: 6 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@
The version is the one Microsoft.Extensions.Http.Resilience above resolves.
-->
<PackageVersion Include="Polly.Core" Version="8.6.6" />
<!--
The phone's talk note shrinks a slide photo to 1600 px JPEG before it
enters the outbox. MIT, with the Android and Windows natives in the box, so
the MAUI head, the browser harness and the unit tests run one codec.
-->
<PackageVersion Include="SkiaSharp" Version="4.152.1" />
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="10.0.11" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageVersion Include="xunit.v3" Version="4.0.0" />
Expand Down
2 changes: 2 additions & 0 deletions src/App/Backlog.Mobile.UI/Backlog.Mobile.UI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<!-- The outbox and the cached inbox: what the phone keeps on itself so a
capture outlives a dead network and the list outlives a cold replica. -->
<PackageReference Include="Microsoft.Data.Sqlite" />
<!-- A talk note's pictures are downscaled on the phone before they queue. -->
<PackageReference Include="SkiaSharp" />
</ItemGroup>

<ItemGroup>
Expand Down
10 changes: 7 additions & 3 deletions src/App/Backlog.Mobile.UI/Components/Pages/Inbox.razor
Original file line number Diff line number Diff line change
Expand Up @@ -237,12 +237,16 @@
get
{
var pending = Outbox.Entries
.Where(entry => entry.Kind == CaptureOutboxKind.Token)
.Where(entry => entry.Kind is CaptureOutboxKind.Token or TalkNoteOutboxKind.Token)
.Select(entry =>
{
var capture = CaptureOutboxKind.Read(entry);
// A talk note is a capture with its uploads in front of it;
// on the list it is the capture it will become.
var capture = entry.Kind == TalkNoteOutboxKind.Token
? TalkNoteOutboxKind.Read(entry).Capture
: CaptureOutboxKind.Read(entry);
return new Row(
new InboxItem(entry.Id, capture.Title, capture.Source, entry.CreatedAt, capture.BodyMd, capture.Tags, capture.Person),
new InboxItem(entry.Id, capture.Title, capture.Source, entry.CreatedAt, capture.BodyMd, capture.Tags, capture.Person, capture.Attachments),
entry);
})
.ToList();
Expand Down
Loading
Loading