Skip to content

iOS build: Ktor networking migration + iOS target#11

Open
nisfeb wants to merge 55 commits into
masterfrom
feat/ios-target
Open

iOS build: Ktor networking migration + iOS target#11
nisfeb wants to merge 55 commits into
masterfrom
feat/ios-target

Conversation

@nisfeb

@nisfeb nisfeb commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Adds iOS as a third target to Talon (previously Android + JVM desktop only) and takes it all the way to a signed TestFlight build. Landed as staged, individually-green commits.

Result

  • ✅ iOS framework compiles (CI ios-compile, release config)
  • ✅ Full app archives on macOS via the Xcode project
  • ✅ Android + desktop compile and the full desktopTest suite pass at every commit
  • ✅ Signed release build uploaded to TestFlight (nomac, asc_build_id f41d54e5…, v0.15.0)

M1 — networking to Ktor

Replaced the JVM-only OkHttp client threaded through commonMain with a multiplatform Ktor client so the shared HTTP surface compiles on Kotlin/Native: UrbitChannel/Session, TlonChatRepo, S3Uploader (SigV4 now via okio + kotlinx-datetime, no JVM crypto), AiClient/AgentClient/McpClient/BraveSearch, UrlFetcher, RelayClient, HttpUpdateChecker, LinkPreviewCache. Tests moved to Ktor MockEngine.

M2 — iOS target + leaf + Xcode project

  • iosX64/iosArm64/iosSimulatorArm64 targets producing the static ComposeApp framework.
  • Full iosMain leaf: every expect actual (Darwin engine, arc4random_buf, NSTimeZone, CoreImage QR, UIKit image picker, Room native under Documents, capability flags), plus persistent SessionStore / AiSettings / ThemePreference and MainViewController.
  • Canonical iosApp Xcode project wiring ComposeApp through the standard Gradle framework-embed run-script phase.

Toolchain — Kotlin 2.1.20 / Compose 1.8.0 / KSP2

Room 2.7.2's iOS klibs are built with Kotlin 2.1.10, so the whole toolchain had to move to read them on native:

  • KSP2 (ksp.useKSP2=true, KSP 2.1.20-1.0.32) — KSP1 drops transitive klibs and crashes validating Room's annotations on native.
  • Kotlin 2.0.20 → 2.1.20, Compose Multiplatform 1.7.3 → 1.8.0 (must move together; the release klib ABI check requires the newer Kotlin/Native compiler).

commonMain — off JVM-only APIs

The Kotlin/Native compile surfaced ~90 JVM-only API uses in shared code that only ever compiled on the JVM. Ported to multiplatform equivalents:

  • String.formatDouble.formatDecimals (+ FormattingTest)
  • ConcurrentHashMapConcurrentMap/ConcurrentSet (atomicfu-locked)
  • java.util.Calendar / java.time.LocalDate → kotlinx-datetime
  • java.io.File → okio-backed deleteFile/readFileBytes/createTempFileUri
  • daemon ThreadGlobalScope coroutine; System.getProperty(os.name)isMacOsHost expect/actual; System.identityHashCodeAny.hashCode
  • Java network exceptions → isTransientNetworkError; StoryCache LRU + synchronized → manual LRU + atomicfu; surrogate-aware codePointAt

Release readiness (App Store lint)

1024 AppIcon (alpha stripped), PrivacyInfo.xcprivacy (required-reason APIs), ITSAppUsesNonExemptEncryption=NO, iOS MARKETING_VERSION 0.15.0, explicit io.nisfeb.talon bundle id. Review lint 🟢 green.

Build infra

  • ios-compile CI job (macOS runner) archives via xcodebuild — the exact TestFlight path — so release-only failures surface for free before spending a nomac build.
  • Gradle heap raised to 6g; the Kotlin/Native release framework link OOMs at 4g.
  • The framework run-script forces the heap via a project-local GRADLE_USER_HOME (-Xmx6g), because the nomac/Codemagic builder pins a smaller org.gradle.jvmargs in its own GRADLE_USER_HOME that outranks the project file. Reported upstream (nomac tkt_xqOTCEKSzcIecLiRQ2zyx); the run-script hack can drop once they raise the builder default.

Follow-ups

  • QrCodeGenerator.ios.kt (CoreImage) compiles but was written without a device — verify the finder-pattern orientation by scanning a generated login QR (NOTE(port/ios)).
  • First real device run of the 26k-line shared codebase — watch for layout/runtime issues via TestFlight feedback.

🤖 Generated with Claude Code

nisfeb added 30 commits July 10, 2026 14:59
Replaces the JVM-only OkHttp client threaded through commonMain with a
multiplatform Ktor client so the shared HTTP surface can compile on a
future Kotlin/Native (iOS) target.

- UrbitChannel/UrbitSession, TlonChatRepo, S3Uploader, AiClient,
  AgentClient, McpClient, BraveSearchClient, UrlFetcher, RelayClient,
  HttpUpdateChecker, LinkPreviewCache all now speak Ktor.
- S3 SigV4 signing moves to okio (SHA-256/HMAC) + kotlinx-datetime
  (UTC ISO-basic timestamps) — no JVM crypto, signs identically on all
  targets. Verified bit-for-bit by S3UploaderTest.
- SSRF post-DNS IP guard stays JVM-only behind createUrlFetcherClient
  expect/actual; the literal-hostname guard stays in common.
- Roots keep an OkHttpClient for the JVM-only leaf consumers (image
  downloaders, weather/digest) and add a shared Ktor client for the
  session/repo/UI path.
- Drops okhttp/okhttp-sse from commonMain deps; switches commonMain
  @volatile to kotlin.concurrent.Volatile.
- Tests moved to Ktor MockEngine (S3, MCP, slash); isInternalAddress
  coverage relocated to desktopTest.

Desktop + Android compile; full desktopTest suite green.
Declares iosX64/iosArm64/iosSimulatorArm64 targets producing the
static ComposeApp framework, and implements the full iosMain leaf so
the shared commonMain compiles on Kotlin/Native.

Cannot be built on this Linux dev box — Apple targets compile only on
macOS; the first real iOS compile happens on nomac's backend. Android
and desktop stay green (verified: compileKotlinDesktop,
compileDebugKotlinAndroid, desktopTest all pass).

iosMain contents:
- expect/actual: Platform (Dispatchers.Default, arc4random_buf,
  NSTimeZone), HttpClientFactory (Darwin), Log, AppContext, UrlFetcher
  (Darwin, hostname guard only), Capabilities (on-device AI/digest/
  loops off, assistant on), EmojiFont, PointerInputs,
  PlatformBackHandler, FileDropTarget, TalonLogo (bundle icon via
  skia), QrCodeGenerator (CoreImage), ImagePicker
  (UIImagePickerController + UIDocumentPickerViewController),
  AppDatabase (Room native + BundledSQLiteDriver under Documents).
- platform services: persistent SessionStore, AiSettings,
  ThemePreference (okio JSON under Documents); UiSettings uses the
  in-memory default for now (DB rail-visibility projection pending).
- MainViewController: ComposeUIViewController { App(...) } wiring the
  six required deps.

Gradle: coil-network-okhttp moved to the JVM leaves; iosMain gets
ktor-client-darwin + coil-network-ktor3; Room KSP runs per iOS target.

Highest-risk files for the first nomac build (heavy blind interop):
QrCodeGenerator.ios.kt (CoreGraphics pixel readback) and
ImagePicker.ios.kt (UIKit delegate bridging).

Remaining for M2: the iosApp Xcode project (deferred to M4 — its
build phases couple to nomac's environment).
The Xcode host nomac builds: an iOS app target that embeds the
ComposeApp framework via the canonical CMP run-script phase
(./gradlew :composeApp:embedAndSignAppleFrameworkForXcode) and mounts
MainViewControllerKt.MainViewController() in a SwiftUI
UIViewControllerRepresentable.

- Bundle id io.nisfeb.talon, display name Talon, min iOS 15.0, via
  Configuration/Config.xcconfig.
- Shared iosApp scheme so xcodebuild -scheme iosApp resolves.
- Info.plist grants photo-library access (image picker) and an ATS
  arbitrary-loads exception (user ships may be plain http).
- Assets: empty AppIcon / AccentColor placeholders.

Unbuildable on Linux — validated only that the Gradle framework tasks
it calls (embedAndSignAppleFrameworkForXcode, linkReleaseFrameworkIos*)
exist. First real build is M4 on nomac; expect pbxproj / signing
iteration there.
Replaces the hand-authored project.pbxproj with canonical output from
the Ruby xcodeproj gem (real UUIDs, well-formed) and removes the
SwiftUI "Preview Content" group (a space-in-path dev-only artifact not
needed for headless builds).

Context: probing nomac's snapshot endpoint showed its Xcode-project
deep-scan returns HTTP 500 (unhandled "internal") above ~200 KB
compressed once an .xcodeproj is detected. A bare project pushes fine;
the full 3.2 MB Talon repo does not. This commit keeps the project
correct for any macOS CI path; the nomac size ceiling is tracked
separately.
Add a macOS job to the test workflow that links the iosArm64 device
framework, forcing KSP and all of commonMain + iosMain to compile.
iOS can't build on the Linux/JVM path, so this is the only automated
iOS signal. Lives in test.yml, not release.yml, since the compile
emits no shippable artifact to package at tag time.
KSP1 only passes a processor's direct klib deps to the Kotlin/Native
compiler, so Room's iosArm64 codegen failed with "KLIB resolver: Could
not find room-common ... klib". Enable KSP2, which resolves the full
transitive klib closure. KSP2 runs codegen in a standalone KspAATask,
so also wire generateTalonBuild as a dependency of the ksp tasks to
satisfy Gradle's input validation. JVM + Android KSP, compile, and
desktopTest verified locally.
KSP2 2.0.20-1.0.25 crashes validating Room's annotation default
values on native (NPE in getDefaultValue, JavaClassImpl cast). KSP
2.0.21-1.0.28 fixes it. The Kotlin patch bump stays within Compose
Multiplatform 1.7.3's supported range. JVM + Android KSP, compile,
and desktopTest verified locally.
Room 2.7.2's iOS klibs are built with Kotlin 2.1.10, so a 2.0.21
Kotlin/Native compiler rejects them (incompatible klib ABI 1.201.0).
Bump Kotlin to 2.1.20 (reads them, forward-compatible), which pulls
Compose Multiplatform to 1.8.0 and KSP to 2.1.20-1.0.32 to stay
aligned. JVM + Android compile and desktopTest verified locally.
The Kotlin/Native compile surfaced ~90 JVM-only API uses in shared
code that only ever compiled on the JVM. Replaced with multiplatform
equivalents:

- String.format -> Double.formatDecimals helper
- ConcurrentHashMap -> ConcurrentMap/ConcurrentSet (atomicfu-locked)
- java.util.Calendar / java.time.LocalDate -> kotlinx-datetime
- java.io.File -> okio-backed deleteFile/readFileBytes/createTempFileUri
- Thread daemon -> GlobalScope coroutine (same fire-and-forget lifetime)
- System.getProperty(os.name) -> isMacOsHost expect/actual
- System.identityHashCode -> Any.hashCode (identity on JVM + native)
- java net exceptions -> isTransientNetworkError helper
- StoryCache LinkedHashMap-LRU + synchronized -> manual LRU + atomicfu
- surrogate-aware codePointAtCommon (no java.lang.String.codePointAt)

Also drops the invalid CoreImage outputImage import and fixes the iOS
KVC setValue import. New util helpers (Formatting, Concurrency, Files,
NetErrors) plus isMacOsHost / tempDirPath expect-actuals. JVM + Android
compile and desktopTest (incl. new FormattingTest) pass locally; iOS
compile verified on CI.
Clear the nomac review-lint blockers for a first TestFlight build:
add a 1024 AppIcon (alpha stripped), a PrivacyInfo.xcprivacy declaring
the required-reason APIs (UserDefaults, file timestamp, disk space,
system boot time), and ITSAppUsesNonExemptEncryption=NO. Bump the iOS
MARKETING_VERSION to 0.15.0 and hardcode the io.nisfeb.talon bundle id
so nomac's snapshot detector resolves it. pbxproj regenerated to
bundle the privacy manifest as a resource.
The pbxproj regen dropped project.xcworkspace/contents.xcworkspacedata;
xcodebuild expects the embedded workspace stub. Restore it.
The Kotlin/Native release link (whole-program optimization over all of
commonMain + iosMain) OOMs at -Xmx4g during the TestFlight archive; the
Debug link and JVM/Android builds fit under it. Bump to 6g. Also set the
framework bundleId explicitly to silence the K/N inference warning, and
switch the CI ios-compile job to the release link so it exercises the
shipping config (where the OOM lived) instead of Debug.
The TestFlight archive OOMed at -Xmx6g even though a plain
`gradlew linkReleaseFrameworkIosArm64` fits in 6g: via xcodebuild's
run-script phase the Kotlin/Native compile runs in the Kotlin daemon,
whose heap is kotlin.daemon.jvmargs, not org.gradle.jvmargs. Set it to
6g. Switch the CI ios job to `xcodebuild archive` (unsigned) so it runs
the same path as the TestFlight build and catches this for free.
nomac's builder sets a small org.gradle.jvmargs in its GRADLE_USER_HOME,
which outranks our project gradle.properties, so the Kotlin/Native
release link OOMs there even though a plain gradlew (and GitHub CI) fit
in 6g. The framework run-script now points GRADLE_USER_HOME at a
project-local gradle.properties with -Xmx6g, which wins regardless of
the builder default. CI simulates a 2g builder default before the
xcodebuild archive to prove the override holds.
The shared Ktor client set only connectTimeoutMillis, so the engine
default read timeout (OkHttp = 10s) leaked in and undercut every
per-call requestTimeoutMillis. A poke to a slow ship that stayed
silent past 10s died with "socket timeout has expired" well before
its own 30s budget. Set socketTimeoutMillis past the longest per-call
budget so the per-call timeout is the real authority; streaming (SSE,
AI) is unaffected since bytes keep arriving.

Also make composer error text copy to clipboard on tap.
Fixes slow-ship poke failures ("socket timeout has expired") and adds
tap-to-copy on composer error text.
AndroidDraftStore updated SharedPreferences on save/clear but only
refreshed its backing StateFlow via the async change listener, which
can miss callbacks. load() reads prefs directly, so after a send the
composer field went empty while the list kept advertising "Draft:".
Refresh backing synchronously from the mutation instead.
The Ktor migration replaced master's hand-tuned OkHttpClient
(readTimeout=0, 15s connect/write, one shared connection pool) with
the bare Ktor OkHttp engine, which inherits OkHttp's default 10s read
timeout and manages its own client. That undercut the per-call poke
timeouts and diverged from the shared-connection transport, which is
why group posts felt slower than before the iOS changes.

Hand Ktor that same client via preconfigured so desktop and android
network exactly as they did pre-migration. This also supersedes the
socketTimeoutMillis workaround: readTimeout=0 is how master kept the
SSE open forever while per-call requestTimeoutMillis bounds pokes.
Pins desktop/android to the pre-Ktor OkHttp transport (faster group
posts, no slow-ship poke timeouts) and clears the stale "Draft:"
badge left in the conversation list after a send.
Two things made a just-sent message sit greyed-out long after it
actually posted, worst on busy group channels:

- The SSE collect loop awaited a network ack after every event, so
  ingestion ran at one round-trip per event. A burst of events (and
  the poke-echo that reaps the pending row) drained only as fast as
  we could ack. Acks now ride their own ordered queue drained off the
  ingestion path, so applyEvent — which does the reap — runs at DB
  speed.
- The chat-list rebuild keyed every message's day with a
  toLocalDateTime (allocation + DST-table lookup) across the full
  history on every insert. A windowed DayKeyer keys by an epoch-ms
  range check, paying a conversion only at a day boundary — back to
  O(distinct days) like the pre-Ktor Calendar path.
Decouples SSE acks from event ingestion and restores the O(distinct
days) chat-list rebuild, so a just-sent message stops showing greyed
long after it posted (worst on busy group channels).
A long prompt grew the field past the bottom of the screen and pushed
the schedule chips, the authorize switch and the Save button out of
reach, with no way to scroll to them. Give the form weight below the
title and wrap it in a verticalScroll, and add imePadding so Save
clears the keyboard while editing.
nisfeb added 25 commits July 12, 2026 15:36
A channel post's grey optimistic twin is reaped when the host echoes
the post back, matched on exact (whom, author, sentMs). If the host
doesn't round-trip essay.sent byte-for-byte the match misses, so the
grey local_ row lingers next to the white server row — the duplicate.

Make reapLocalTwin report how many rows it removed; on an exact miss
for a fresh echo, fall back to reaping the oldest still-pending twin
(echoes arrive in send order). A recency guard keeps a re-delivered
old history item from clobbering a different in-flight post, and the
miss is logged so a sent-skew failure is distinguishable from a slow
one. Covered by MessageReapDaoTest.
Clears the grey+white duplicate when the host skews essay.sent (with
a diagnostic log for sent-skew vs slow reap), and makes the loop
editor scroll so Save stays reachable with a long prompt. Rides on
rc4's SSE ack-decoupling throughput work.
Loops can now run at a set local time on chosen weekdays (e.g. 6am
Sunday), not only at intervals. Adds scheduleKind/atMinuteOfDay/
daysMask to LoopEntity (db 38->39, Android migration + destructive
fallback on desktop), weekly next-fire math in LoopSchedule, and an
editor toggle with a time input and day chips. Empty day set fires
every day; device-local zone throughout.

Every headless run now prefixes the prompt with the current local
date, weekday, and time so the agent can reason about "now".

Also pad the loop editor with the system-bar inset so a long prompt
no longer overruns the top menu bar.
%groups sends the leaving member no delete fact (only a host deleting
the group emits one), so a left group lingered in the home list until
the next full /v2/groups reconcile — i.e. an app restart. Register the
group-leave poke id and, on the ship's ack, remove the group and its
channels locally; on a nack keep it and log, since the leave didn't
take. Mirrors tlon-apps' optimistic leave, adapted to the async ack
model. A silent nack and a silent success were previously
indistinguishable.
Brings the whole toolchain to latest stable: Compose Multiplatform
1.8.0 -> 1.11.1 (skiko 0.9.4 -> 0.144.6), Kotlin 2.1.20 -> 2.2.20,
KSP -> 2.2.20-2.0.4, Room 2.7.2 -> 2.8.4, androidx.sqlite -> 2.6.2.

The skiko jump is the point: a native font-stream crash
(SkTypeface_fontconfig::onOpenStream, plus heap-corruption SIGABRTs)
had been killing the desktop AppImage roughly weekly. That class of
bug is fixed ~140 skiko milestones on from 0.9.4.

Fallout handled:
- kotlinx-datetime 0.6.1 -> 0.7.1 (forced by the new stack). 0.7
  moved Instant/Clock to kotlin.time; migrated every call site and
  opted into kotlin.time.ExperimentalTime once for all targets.
- Dropped the iosX64 target: Compose 1.11 no longer publishes Intel
  iOS-simulator artifacts. Device + Apple-silicon simulator remain.

Verified on desktop + Android compile and the full desktop test
suite. iOS builds on macOS only (nomac/CI).
Two things surfaced investigating slow sends / lingering grey twins:

- applyChannelDelta's PostsBatch branch never reaped our own grey
  optimistic twins — only the singular PostSet/Reply paths did. An
  echo that arrives batched (SSE reconnect, paginated catch-up) left
  the grey duplicate on screen until the next full bootstrap. Reap
  own posts in the batch too; the exact-sentMs match no-ops on
  re-delivered history so it's safe.

- The desktop Log wrote only to stderr, which a GUI-launched AppImage
  sends to /dev/null — so there were no logs to diagnose from. Add a
  wall-clock-stamped file sink at <userData>/log/talon.log (rotated
  at 2 MB). reapOwnEchoTwin now logs the send->echo round-trip
  latency, the single number that says whether slow sending is the
  network round-trip or our own ingest.
Investigation of "slow message sending" (with the new file log):
the entire ~1.5-2s send delay is the channel poke's HTTP PUT, which
eyre holds until %channels finishes processing the post. Everything
downstream — ship echo, our SSE ingest, the grey→white reap — totals
under 15ms.

Measured out the alternatives:
- raw HTTPS RTT to the ship: ~150ms
- small authenticated scry: ~150ms
- a channel post-add poke: ~1500ms (even to a locally-hosted channel,
  so it isn't Ames forwarding)
- forcing HTTP/1.1 (vs shared-HTTP/2): no change, so it isn't the SSE
  head-of-line-blocking pokes on a shared socket either

So it's the ship's gall compute on a heavy ship (2MB groups state,
138 channels), not our transport and not the Ktor migration — the
grey/pending machinery is byte-identical to master and predates this
branch. Keep the two timing probes but only warn past 1s, so a laggy
ship self-reports without spamming a normal send.
coroutines 1.8.1->1.11.0, serialization 1.7.2->1.11.0, atomicfu
0.25.0->0.33.0 (align with Kotlin 2.2.20); security-crypto alpha06
->1.1.0 stable, okio 3.9.1->3.17.0, sqlite-jdbc ->3.53.2.0,
work-runtime ->2.11.2, slf4j ->2.0.18. Desktop+Android compile and
full desktop tests green.
AGP 8.7.3->8.13.2, Gradle 8.10.2->8.14.5, compileSdk/targetSdk
35->36; ktor 3.0.3->3.5.1, coil 3.0.4->3.4.0, composeBom ->2026.06.01,
reorderable 2.5.0->3.1.0.

Coil held at 3.4.0 (not 3.5.0): 3.5.0 requires kotlin-stdlib 2.4.0,
unreadable by the Kotlin 2.2.20 compiler our CMP 1.11.1 / Room / iOS
foundation is pinned to. 3.4.0 (stdlib 2.3.10) is the newest the
2.2.20 compiler can read. Green on desktop+android+tests.
core-ktx 1.13.1->1.19.0, activity-compose 1.9.2->1.13.0, lifecycle
2.8.6->2.11.0, media3 1.4.1->1.10.1 — all require AGP 9.1+, so:
AGP 8.13.2->9.3.0, Gradle 8.14.5->9.6.1.

AGP 9 dropped com.android.application x KMP-plugin compat. Bridged
with the official android.builtInKotlin=false / android.newDsl=false
flags rather than the full com.android.kotlin.multiplatform.library
migration (tracked as follow-up; flags go away in AGP 10). Fixed one
Gradle-9 break: providers.provider now rejects a nullable value type
(Provider<File?> -> Provider<File>). Deprecation warnings remain for
the android{} DSL and source-set getting-delegates (removed in
Gradle/AGP 10). Green on desktop+android+tests.
okhttp 4.12.0->5.4.0 (major, source-compatible for our Builder usage),
djl 0.34.0->0.36.0, mediapipe-tasks-text 0.10.21->0.10.35, unifiedpush
3.0.5->3.3.3, zxing-core 3.5.3->3.5.4. Green on desktop+android+tests.
Latest stable; stdlib 2.1.20, compatible with the 2.2.20 compiler.
Green on desktop+android+tests.
Compose 1.11 dropped the iosX64 target, so the ComposeApp framework
is arm64-only. The simulator build still requested x86_64, failing
syncComposeResourcesForIos with 'Unknown iOS simulator arch: x86_64'.
Exclude x86_64 for the simulator SDK in Config.xcconfig.
nomac provisions JAVA_TOOL_OPTIONS=-Xmx6g on every build, so the
run-script no longer needs its own GRADLE_USER_HOME. The separate
.gradle-ios-heap started empty each build and re-downloaded every
klib; under gradle 9 one components-resources artifact transform
failed to materialize, breaking iosSimulatorArm64 klib resolution.
Reverting to the default cache lets nomac's warmed ~/.gradle serve
the klibs.
compose multiplatform 1.11.1 ships ios klibs built by the 2.3.20
compiler (abi 2.3.0). kotlin 2.2.20 caps at abi 2.2.0, so the
native target could not resolve components-resources on ios. jvm
and android tolerated the skew; kotlin/native klib abi does not.

kotlin 2.3.20 matches both cmp's compiler and the stdlib that ksp
2.3.10 embeds. kotlin 2.4.x stays out of reach until ksp ships a
2.4 line (room codegen needs ksp). desktop + android compile clean
and the full desktop test suite passes.
ktor's sse plugin left eyre holding the channel GET open but never
surfaced the facts. the field symptom: the 90s watchdog reconnected
forever with zero events, so every optimistic grey post was stranded
(its echo never arrived to reap it) and each reconnect's re-scry
re-added the post as a second row — the duplicate-message bug.

the transport and cookies were fine (proven by test); the plugin's
request/response handling was the regression from the okhttp
EventSource the pre-ktor build used. read the event-stream directly:
a plain streaming GET with accept-encoding identity so eyre emits
each frame uncompressed the instant it is ready, parsed frame by
frame in commonMain so every platform shares it.

adds UrbitChannelSseTest driving the real events() path against a
held-open local server.
the live SSE delta path reaps our optimistic local_ post twin when
the server echoes it back, but the init-posts re-scry did not. so a
post whose SSE echo was missed (stream dropped between poke and echo)
kept its grey twin while the next reconnect re-scry re-added the same
post under its real id — two rows, the duplicate-message bug.

reap the exact (whom, author, sentMs) twin for every own top-level
real post the snapshot ingests. exact-match only, so a mid-flight
snapshot can't clobber a different still-pending post's twin via the
FIFO fallback. clears stranded twins on the next reconnect no matter
why the echo was missed.
refreshConversation re-scries a single whom and re-adds our own
posts under their real id, the same duplicate path the init-posts
bootstrap had. apply the identical exact-match twin reap so opening
a conversation can't strand a grey+white pair either.
checkReleaseAarMetadata failed the rc8 android build: core-ktx
1.19.0, core 1.19.0 and lifecycle-runtime-compose 2.11.0 all require
compiling against api 37. that check only runs in the assemble path,
so the package-upgrade loop (which built compileDebugKotlin, not
assembleRelease) never caught it. bump compileSdk to 37; targetSdk
stays 36 so we don't take android 17 runtime behavior in the same
step. release.yml installs the platform since the runner image may
not preinstall it. verified with a local assembleRelease.
opening a thread on a phone (or any compact window) swaps DmChatScreen
out for a full-screen ThreadScreen — they are mutually exclusive
when-branches. the chat's LazyListState and first-anchor flag were
owned by DmChatScreen, so the unmount dropped them; on return the
anchor effect re-snapped to the newest message. with 50 unreads that
dumped the user at the bottom, forcing a long scroll back up.

hoist the scroll state and anchor flag into the callers (App.kt and
android TalonApp.kt), keyed on the open chat: they survive the thread
round-trip and reset only on a real chat switch, so a freshly opened
conversation still snaps to newest. wide windows never unmounted the
chat (thread is a right pane) and are unaffected.
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.

1 participant