fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.
Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
RichTextParser.fixMissingSpaces used a Regex of the form
`([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to
neighbouring text. Kotlin/Native's regex engine fails to backtrack the
optional `([^ \n])?` capture groups to zero width, so on iOS every URL was
corrupted (e.g. "https://x" became "h https://x"). That broke the
downstream segmenter, which is why :commons:iosSimulatorArm64Test reported
19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary
finally linked.
Replace the regex with a direct left-to-right scan that inserts a single
space wherever a detected URL touches a non-space/non-newline neighbour. The
scan is engine-independent, so it behaves identically on JVM and Native.
Verified equivalent to the old behaviour across the full commons richtext
JVM corpus, and the new FixMissingSpacesTest pins the cases on every target
(including iosSimulatorArm64).
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).
Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
Backs out the reactive list-refresh plumbing added in the prior commit:
removes the `changes` SharedFlow from the shared `ChatroomList` (restoring
it to its original form) and reverts Desktop `ChatroomListState` to its
original 2s poll. Room assembly is expected to move to a LocalCache.observe
approach on both platforms later, which would supersede this.
Keeps the independent Desktop list improvements (per-room unread tracking
and the mute/acceptable filter), which don't depend on the flow.
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
The test-quartz-ios job only compiled :commons production code for iOS
(compileKotlinIos*), never the commonTest source set, so a test using a
JVM-only API compiled fine on JVM/Android and shipped green while silently
breaking compileTestKotlinIosSimulatorArm64.
Mirror the quartz step: run :commons:iosSimulatorArm64Test (compiles +
runs the shared commonTest suite on the simulator) and
:commons:compileTestKotlinIosArm64 (device-only compile drift). This keeps
commonTest KMP-clean going forward.
Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.
- commons ChatNewMessageState: actually attach the composed NIP-14 subject
to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
header and add a rename dialog that broadcasts a subject change to all
members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
room (covers kind 14/15 and future variants) and store self-authored
NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
(with a slower safety poll), track real per-room unread via a last-seen
mark, and hide rooms whose latest message isn't acceptable (mute/filter).
https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
A kind:1 note carrying a `q` tag is a quote-repost of the quoted note,
but Amethyst's reaction-row repost counter reads `Note.boosts`, which
only collected kind:6/kind:16 reposts. Quote-reposts were treated purely
as inline citations (stripped by `tagsWithoutCitations()`), so they never
appeared in the quoted note's repost count.
LocalCache now adds a `q`-tagged note as a boost of each quoted note when
consuming text notes/comments, and detaches it on deletion. The quoted
note is deliberately kept out of `replyTo` so the quote still renders as a
root post in the home feed (`Note.isNewThread`). The same wiring is added
to DesktopLocalCache for parity, with tests pinning the behavior using the
exact event reported.
The commons commonTest source set is shared across all KMP targets,
including the iosArm64/iosSimulatorArm64 spike, but several tests still
reached for JVM-only APIs that don't resolve on Kotlin/Native:
- JUnit (`org.junit.*`, `junit.framework.TestCase`) → kotlin.test, with
message arguments moved from first (JUnit) to last (kotlin.test).
- `assertArrayEquals` → `assertContentEquals`.
- `@JvmStatic` on the `android.util.Log` test stub → removed (it only
affects JVM bytecode; companion calls work without it).
- `seg.javaClass.simpleName` → `seg::class.simpleName!!`.
- A test function name containing `()` (illegal on Native) → renamed.
- `String(CharArray, offset, count)` → `CharArray.concatToString`.
CliffDetectorTest exercises `computeStalledSpeakers`/`defaultCliffBackoffMs`,
which live in the jvmAndroid-only NestViewModel and are invisible to iOS,
so it moves to jvmTest alongside NestViewModelTest.
The in-stream loading marker spelled out "N relays" only for the fully-loaded
(done) chip; active frontiers showed a bare count ("Loading: ↓ 8"). Use the
relays plural for the count fallback on every state so a count chip always reads
as a sentence ("Loading: ↓ 8 relays"). 1–2 short host names still spell out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BackwardRelayPager exposed five independently-updated StateFlows (exhausted,
relayCount, stalledCount, reachedBack, relayProgress) that are all recomputed
together on every page settle. Co-located consumers therefore paid up to five
separate recompositions per settle and could observe a torn read (e.g. an
updated relayCount against a still-stale relayProgress).
Combine them into one atomic PagingStatus snapshot, emitted by a single
publish(), collected once. updateStatus()/recomputeExhausted() merge into that
publish() (exhausted computed inline). loadingMore stays separate: its falling
edge is debounced on its own timer in PerRelayLoadTracker, decoupled from the
status recompute, so folding it in would miss that delayed transition.
Threaded through the 3 history managers and the 3 feed consumers
(ChatroomListFeedView, ChatroomView, LoadingReplyNote): 12 collectors -> 4 at
the heaviest views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:
- uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
- uploadPassesAuthHeaderToClient (.txt)
- uploadPassesSameFileWhenNoStripExif (.txt)
- uploadComputesMetadata (.txt)
The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).
Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:
1. UploadOrchestrator.upload's quality parameter is now nullable
(CompressionQuality? = null). Null means "do not reencode" —
matches the orchestrator's behavior before this feature, so the
Android, CLI, and any non-image upload path keeps working
unchanged. The desktop compose flow continues to pass a non-null
CompressionQuality so it still runs the reencoder.
2. ImageReencoder no longer throws UnsupportedFormat for
ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
AVIF and HEIC still throw (those are recognized formats we
explicitly refuse). The new PassReason.NotAnImage is rendered in
the preview dialog as "Not an image · uploaded as-is" / "Metadata
preserved (non-image — no re-encode applies)".
My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
In the lightbox/carousel:
- Hover over the image → Material3 PlainTooltip shows the full
Blossom URL above the image (TooltipAnchorPosition.Above, 8 dp
gap). Same TooltipBox pattern already used in
MediaServerSettings.
- Single-click on the image → copies the URL to the system
clipboard via AWT Toolkit, then surfaces a green snackbar
banner at the top: "Copied <url> to clipboard". The banner
slides in from above, sits below the download banner if both
fire simultaneously, and auto-dismisses after 2.5 s
(LaunchedEffect on the message state).
- Double-click still resets zoom — unchanged.
- The MoreOptionsMenu's "Copy URL" rows on both the image and
video paths now route through the same copyUrlToClipboard
helper so they also trigger the snackbar (previously they
copied silently with no user feedback).
ZoomableImage gains an `onTap: (() -> Unit)?` parameter; null
keeps the old "consume single tap" behavior, set means the caller
handles the click (lightbox uses it for the copy action).
"Cancel" implies the post is being abandoned. The actual behavior
is to return to the compose dialog with attachments still attached
so the user can adjust quality, swap files, or change copy before
re-triggering Preview. "Back" matches the semantic.
Reported via runtime crash dialog on the user's first PNG upload:
Exception in thread "AWT-EventQueue-0":
java.lang.IllegalStateException: Can't overwrite cause with
javax.imageio.IIOException: Bogus input colorspace
at java.lang.Throwable.initCause(Throwable.java:464)
at CompressionException.<init>(CompressionException.kt:39)
Two real bugs:
1. CompressionException constructor double-set the cause.
Exception(message, cause) super already wires the Throwable's
cause slot; the init block then called initCause(cause) AGAIN
which throws IllegalStateException by spec ("Can't overwrite
cause"). The init block was added per a code-review note that
was wrong about how Kotlin's primary constructor forwards
cause. Removed the init block; relying on super does the
right thing. Regression: encodeFailedWrapsCauseWithoutCrashing.
2. JPEG writer rejected non-RGB BufferedImages with "Bogus input
colorspace". TYPE_INT_ARGB (typical PNG decode), TYPE_BYTE_GRAY,
TYPE_CUSTOM (CMYK JPEGs, indexed PNGs) all blow up the stock
JPEGImageWriter. encodeJpeg now flattens via toRgbCanvas — draws
onto a fresh TYPE_INT_RGB canvas with white background for any
transparent pixels. White matches what every major image viewer
does for transparent PNGs over a light surface.
Regression: reencodesPngWithAlphaToJpeg.
Both regressions are covered by new tests so the patterns cannot
silently come back. Reencoder test count: 13 -> 15.
Reworked the per-row toggle in CompressionPreviewDialog to match the
user's actual intent. Previously the Switch meant "exclude this
attachment from the post entirely"; now it means "upload the original
bytes instead of the compressed version" — which is the only
meaningful per-row choice once you've already attached something.
Behavior:
- Toggle off the compression on a Reencoded row → orchestrator
uses bypassReencode=true (= upload original), the cached
compressed temp is deleted right before upload so it never
leaks.
- The Publish button no longer changes count or disables —
everything attached gets uploaded.
- Cancel still cleans up every cached compressed temp.
Layout fix the user called out:
- Only the compressed half of the row dims (thumbnail + arrow).
The original thumbnail stays full-color because that's what's
actually being uploaded when "use original" is on.
- The stats/savings line is replaced by "compression skipped —
original uploads as-is" when toggled.
- The metadata-strip sub-line now flips dynamically:
compressed → "All EXIF, GPS, camera tags stripped (re-encoded)"
original + strip ON + JPEG → "EXIF, GPS, camera tags stripped
from original before upload"
original + strip ON + non-JPEG → red warning: "Metadata
preserved — strip only runs
on JPEG; original is non-JPEG"
original + strip OFF → "Metadata preserved (EXIF strip off
in settings)"
Style fix: replaced the chunky Switch with a small TextButton —
"Use original" by default (muted color) → "Using original — undo"
when active (error color). Matches the rest of the dialog's
TextButton + DropdownMenu vocabulary; reads as a desktop action,
not a mobile preference.
The toggle is intentionally removed from PassThrough / Failed /
NonImage rows — those have no per-row choice (always-as-original
by design) and a control there would be deceptive.
API change: CompressionPreviewDialog.onPublish is now
(List<PreviewItem>, useOriginalPaths: Set<String>) -> Unit.
runPublish in ComposeNoteDialog routes Reencoded items in the
useOriginalPaths set through orchestrator.upload(bypassReencode =
true) and deletes the unused compressed temp inline.
Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.
Per-row skip toggle:
- Every preview row gains a Switch labeled "Include" / "Skipped"
(the verb is shown so the user can't misread a bare switch).
- Skipped rows dim the thumbnail (0.4 alpha) and tone down the
surface, hide the "Click to compare" hint, and disable the
click-to-zoom.
- Publish button label now reflects the included count —
"Publish (4)" when nothing skipped, "Publish (3 of 5)" with
skips, "Nothing to publish" + disabled state when all skipped.
- On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
included subset is handed off to UploadOrchestrator via the
preCompressed param as before.
- onPublish signature changed: (List<PreviewItem>) -> Unit, and
runPublish in ComposeNoteDialog now takes the filtered list
rather than reading pendingPreview directly.
Explicit metadata-strip status on every row:
- Reencoded rows: "All EXIF, GPS, camera tags stripped
(re-encoded to JPEG)" in the tertiary color. Re-encode wipes
metadata regardless of the strip-EXIF setting because we
don't preserve any metadata in the JPEG writer.
- PassThrough rows:
Animated → "Metadata preserved (animated — re-encode would
drop frames)"
Vector → "No raster metadata (SVG)"
Bypass → "Metadata preserved per your override"
- Failed rows (going to send original):
JPEG + strip on → "EXIF, GPS, camera tags stripped before
upload" in tertiary color
non-JPEG + strip on → "Metadata preserved — strip only runs
on JPEG; this is <Format>" in error
color (privacy warning)
strip off → "Metadata preserved (EXIF strip off in settings)"
- NonImage rows: "Metadata preserved — EXIF strip applies to
JPEG only"
The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
When the post has image attachments, the Publish button now reads
"Preview" instead. Clicking it runs ImageReencoder on every
attachment eagerly, then opens CompressionPreviewDialog with one
row per file:
- Reencoded rows: original thumbnail → compressed thumbnail +
dims/sizes/savings % + chip showing the active quality preset.
Click the row to open a side-by-side ZoomCompareDialog with
420 dp images and a "Saves N%" header.
- PassThrough rows: original thumbnail + "Animated / Vector ·
uploaded as-is" assist chip — covers animated GIF, animated
WebP, SVG, and the bypass-by-user path.
- Failed rows: original thumbnail + red-bordered surface +
"Could not compress: <reason>" + the privacy hint
("EXIF will be stripped" for JPEG, "metadata may still be
present" for non-JPEG). User can still publish — original
bytes ship.
- NonImage rows: filename + extension badge + "uploaded as-is"
for any non-image attachment caught up in the batch.
The dialog's Publish button calls the same runPublish lambda the
main button uses. The lambda walks the preview items and tells the
orchestrator either:
- preCompressed = <cached temp> for Reencoded,
- bypassReencode = true for Failed,
- default flags for PassThrough / NonImage.
UploadOrchestrator.upload gains a `preCompressed: File?` param
so the dialog can hand off ownership of the cached temp; the
orchestrator deletes it after the actual upload in the same
finally block.
Cancel cleans up every cached temp via cleanupPreviewTemps so a
dismissed preview doesn't leak.
The standalone CompressionFailureDialog from Phase 7 is now
unreachable (all failures surface inline in the preview), so it
gets deleted. The shared `runPublish` lambda was hoisted out of
the Card into the composable's top scope so both the main button
and the preview's onPublish callback can call it.
Triggered by the user's manual-testing feedback: "shouldn't I
preview the compressed images before publishing the note?" — the
plan's deferred compare dialog became the natural publish gate.
The options row (Upload to / Quality / Post as) was wrapping
"Note" to two lines at the 600 dp dialog width — see the
screenshot the user surfaced during manual testing.
- Bumped the compose dialog from 600 dp to 780 dp and added
DialogProperties(usePlatformDefaultWidth = false) so the
explicit width is honored.
- Added maxLines=1 + softWrap=false to all three selector
TextButton labels (ServerSelector, QualitySelectorChip,
PostTypeSelector) so they can never wrap regardless of
future attachment count or label growth.
Also threads through a new preCompressed: File? param on
UploadOrchestrator.upload — landed early because the preview-
gate work needs it. When the upcoming CompressionPreviewDialog
hands off a pre-computed temp, the orchestrator skips reencode
+ stripExif and just uploads + cleans up.
Manual testing feedback from the UI:
- The Post-as Note/Picture FilterChip pair was overflowing the
options row and rendering "Picture" rotated 90°. Converted it
to the same Text + TextButton + DropdownMenu pattern as the
sibling Upload-to and Quality controls so the row stays
compact and visually consistent.
- QualitySelectorChip was likewise a FilterChip; rewritten as
Text + TextButton + DropdownMenu to match. Dropdown rows now
carry a two-line layout: bold preset label (e.g.
"Medium (640 px)") with a sub-line summary explaining the
tradeoff ("640 px · balanced size and quality").
- Restored the HIGH preset (640 px @ q=0.85, "visually lossless
on phones") between Medium and Desktop High. The four-preset
set is now Low / Medium / High / Desktop High — closer to the
Android-parity progression the brainstorm originally specced.
- CompressionQuality enum gains a `summary` field (one-line
description) plus a `chipLabel` convenience ("Medium (640 px)").
Settings panel and dropdown both consume `summary` so users
see what each preset actually does without trial-and-error.
ImageReencoderTest gains a HIGH-preset test and the monotonicity
test now asserts LOW < MEDIUM < HIGH for the same-dim subset.
Phase 7 of the desktop image compression plan.
Never silently downgrade — when ImageReencoder throws
CompressionException for one or more attachments (UnsupportedFormat,
InputTooLarge, EncodeFailed), the compose dialog now surfaces a
modal listing each failure and lets the user choose between Send
Original (uploads raw bytes, EXIF-stripped for JPEGs) or Cancel
post.
- UploadOrchestrator gains bypassReencode: Boolean = false. When
true, the orchestrator skips ImageReencoder and treats the
source as a PassThrough(BypassByUser), preserving the EXIF
strip + temp-cleanup semantics from the normal pass-through
path.
- ImageReencoder.PassReason gains BypassByUser.
- CompressionFailureDialog is built as Dialog { Surface } (not
AlertDialog) because:
* the body is a LazyColumn that grows with N failures —
AlertDialog's `text` slot has fixed-width constraints,
* LaunchedEffect / produceState don't fire inside
AlertDialog.text (see custom-feeds-alertdialog.md memory),
leaving room for future per-row actions (e.g. per-file
retry).
Each row shows: filename, "Could not compress: <reason>" in
error color, original byte count, and a privacy hint that
differs by source format ("EXIF will be stripped" for JPEG
bypass, "metadata may still be present" for PNG/HEIC bypass).
- ComposeNoteDialog send loop now wraps each upload in try/catch
(CompressionException), collects failures, and after the loop
awaits the user's choice via CompletableDeferred<FailureUserChoice>.
On SendOriginal: re-uploads each failure with
bypassReencode=true. On Cancel: aborts the post.
Phase 6 of the desktop image compression plan.
ClipboardPasteHandler previously wrote clipboard_*.png to /tmp with
deleteOnExit, leaking across long-running JVM sessions (the
existing leak documented in docs/temp-file-cleanup-analysis.md
under "Desktop temp files (out of scope for this change)").
Now lands under AmethystTempDir as
amethyst_paste_YYYYMMDD-HHMMSS_<rand>.png
so:
- the boot-time orphan sweep recovers it if the JVM crashes
before the upload pipeline consumes it,
- the directory mode (0700 on POSIX) keeps it out of reach of
other local users on shared systems,
- the filename surfaces the paste timestamp for diagnostics.
The PNG roundtrip is unavoidable: Java's clipboard image flavor
only exposes BufferedImage, so PNG is the lossless container we
materialize before the orchestrator's ImageReencoder decides to
re-encode at the active quality preset.
Phases 4 + 5 of the desktop image compression plan, landed together
because they touched the same send-loop / options-row code.
- New QualitySelectorChip composable (FilterChip + anchored
DropdownMenu) shows "Quality: <displayName>" and visually
highlights when the user has overridden the global default.
Reset row appears only after the user has chosen an override.
- ComposeNoteDialog gains:
* defaultQuality / stripExifSetting read from
ImageCompressionStore via .collectAsState() so changing the
Media settings panel updates the compose dialog live,
* perPostQualityOverride: CompressionQuality? for one-off
overrides scoped to the current post,
* activeQuality = override ?: default,
* QualitySelectorChip wired into the existing options row
(when attachments include images), next to the
ServerSelector and PostTypeSelector,
* the upload loop now passes both stripExif and quality
through to UploadOrchestrator.upload(),
* inline batch progress via the existing tracker fileName
slot — "1/3: foo.jpg", "2/3: bar.jpg", "3/3: baz.jpg" —
no new tracker state class needed,
* perPostQualityOverride resets after a successful send so
the next post starts from the saved default again.
Phase 3 of the desktop image compression plan.
ImageCompressionSettings composable in
desktopApp/.../ui/settings/, modeled on the existing
MediaServerSettings shape:
- Section header "Image Compression"
- Default quality as SingleChoiceSegmentedButtonRow (Low / Medium
/ Desktop High), matching the codebase convention from
TorSettingsSection / FeedBuilderDialog (NOT a DropdownMenu).
- Per-preset hint text underneath the segmented row, refreshed
reactively as the user clicks.
- Strip-metadata Switch with hint "Removes camera, GPS, and
timestamp data from uploaded photos."
State flows from ImageCompressionStore via .collectAsState() — uses
the JVM-portable Compose API (collectAsStateWithLifecycle is
Android-only and is not used anywhere in desktopApp).
Integrated into Main.kt:1791 just below MediaServerSettings,
surrounded by the standard HorizontalDivider + Spacer rhythm.
Per-post override (compose dialog) will read from the same store
in Phase 4.
Phase 2 of the desktop image compression plan.
- DesktopPreferences gains two raw prefs:
KEY_IMAGE_QUALITY (default "DESKTOP_HIGH") and
KEY_IMAGE_STRIP_EXIF (default true). Marked internal — callers
should go through ImageCompressionStore, not the raw prefs.
- ImageCompressionStore mirrors SearchHistoryStore: object
singleton, init seeds StateFlow from prefs, setters write
through to both StateFlow and prefs in one shot. Exposes
quality: StateFlow<CompressionQuality> and stripExif:
StateFlow<Boolean> for Compose reactivity.
Per-post override state lives in ComposeNoteDialog (Phase 4), not
here — this store carries only the default that the override falls
back to.
Phase 1 part C — the integration that makes the new compression
pipeline actually run on every upload.
- UploadOrchestrator.upload gains an optional quality parameter
(default CompressionQuality.DESKTOP_HIGH). The orchestrator now:
1. Runs ImageReencoder.reencode — branches on Reencoded vs
PassThrough.
2. For PassThrough + stripExif=true + JPEG source, runs
MediaCompressor.stripExif so animated/SVG passes still get
EXIF stripped where applicable.
3. Computes metadata on the bytes that will actually leave
the machine.
4. Eager-cleans up every intermediate in a finally{} block
wrapped in NonCancellable so user-cancelled uploads don't
leak temps.
Throws CompressionException for the fail-loud dialog path.
- MediaCompressor.stripExif drops deleteOnExit (long-running
desktop process was leaking stripped_*.jpg per
docs/temp-file-cleanup-analysis.md). Temp files now land under
AmethystTempDir with the amethyst_stripped_ prefix; caller owns
cleanup.
- AmethystTempDir lazy-initializer now triggers sweepOrphans() on
first access, recovering any amethyst_* files > 24h old left
behind by JVM crashes that skipped shutdown hooks.
- BlossomClient: open class + open upload methods so
UploadOrchestratorTest can substitute a FakeBlossomClient that
captures the uploaded file's bytes.
- jvmTest now pulls secp256k1.kmp.jni.jvm so end-to-end signer-
based tests (Blossom auth event signing) can run.
5 new orchestrator tests cover: JPEG re-encode path (uploaded file
is the AmethystTempDir temp, smaller than source, hash matches
captured bytes), animated GIF pass-through (uploaded == original),
AVIF refused with UnsupportedFormat (client.upload never called),
temp cleanup on success, and temp cleanup on upload failure.
Test totals across commons jvmTest upload package: 44 / 44 green
(4 smoke + 23 sniffer + 12 reencoder + 5 orchestrator).
Phase 1 part B. The core re-encode + downscale pipeline:
- AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
boot-time sweep of amethyst_* files > 24h. Defends against
/tmp tmpfs OOM on Linux VMs and against multi-user temp races
on shared systems. Overridable via -Damethyst.tmp.dir=.
- ImageReencoder.reencode(File, CompressionQuality) returns a
sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
and throws CompressionException for fatal cases.
* Format sniffer first → pass-through for animated GIF /
animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
* Pre-decode pixel guard: stream header dims via
ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
any pixel buffer is allocated.
* Subsampled decode (floor stride) so a 4032×3024 source decodes
to ~2016×1512 in heap before Thumbnailator's final resize.
* Never upscale: Thumbnails.of(...).size() is only called when
the source actually exceeds the target box.
* CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
with ensureActive() between stages.
* Cleanup on cancellation/failure runs in NonCancellable.
12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.
ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
Per .claude/CLAUDE.md: per-module plans live in the owning module's
plans/ folder. The global docs/plans/ is frozen. The image compression
feature is desktop-driven (commons gets the backing pipeline), so the
owning module is desktopApp.