Skip to content

feat(cmp): big switch — whole app UI from Kuikly to Compose Multiplatform - #3192

Merged
stamp-build[bot] merged 105 commits into
mainfrom
agent/lagoon/cmp-big-switch
Oct 20, 2026
Merged

stamp-build[bot] merged 105 commits into
mainfrom
agent/lagoon/cmp-big-switch

Conversation

@stamp-build

@stamp-build stamp-build Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Moves the whole mobile UI from Tencent Kuikly to JetBrains Compose Multiplatform 1.8.2 in one step, per artin: migrate everything first, fix bugs after. Kuikly UI is removed from Android and iOS.

This supersedes #3191 (the CMP shell; iOS cannot link Kuikly's Compose-1.7 klibs next to the CMP 1.8 runtime) and #3185 (Kuikly skills removal, folded in here).

What changed

  • :shared has no Kuikly UI dependencies. The compose fork, core, render, the KSP page processor and mermaid-kuikly are removed.
    • Imports are rewritten by scripts/cmp_migrate_imports.py, which can be re-run.
    • Pages render through a generated registry (scripts/cmp_generate_page_registry.py). Page names and the pageData contract are unchanged.
  • Compat layer in compose/shared/.../app/compat/ keeps Kuikly names so call sites barely change: JSON, Pager/PageData, the Module bridge, KuiklyFont, keyboard height, BackHandler, pull-to-refresh.
  • Raft implementations of native pieces:
    • video: ExoPlayer and AVPlayer, paused when the app is backgrounded;
    • view capture: graphics layer to PNG;
    • Lucide icons: generated Compose ImageVectors;
    • inline markdown chips: annotations plus drawBehind;
    • Mermaid: a commonMain Canvas renderer;
    • images: Coil 3 over NetworkKMM, with the ci(visual-testing): analysis via pi-SDK — drop codex install, wire PI_API_KEY→MINIMAX_API_KEY #403 content key, bounded decode, an image-sniffed disk cache cleared on logout, and a 24h TTL for external images;
    • WebView: Android WebView and WKWebView ports, same sandbox and origin fences, fence tests ported;
    • selectable text: native TextView and UITextView (task Persist diagnostics by cold-launch session #924);
    • a drawer, M3 pull-to-refresh, and composer Enter/Shift+Enter decided on key-down. Send reads the live value with the IME composition finished.
  • Android host: every page goes through SlockCmpActivity, and the Kuikly host is deleted.
    • RaftHostActivity carries the native capabilities, with a sync bridge path for the sync calls.
    • Back handlers are scoped to the active page.
    • Module callbacks are dropped after a page is destroyed.
  • iOS host: every page is a RaftPageViewController embedding the CMP page, and the Kuikly pod and KuiklyExpand are deleted.
    • Bridge calls go per page through HRBridgeModule.
    • Host events (insets, pane, theme) reach the page.
    • Kotlin unhandled exceptions are recorded in diagnostics.
    • The bridge selector is pinned, with an XCTest guard.
  • CI and scripts:
  • Docs: AGENTS.md no longer describes Kuikly; the Kuikly skills, rules and the KuiklyUI reference submodule are gone.

Verification

  • Unit tests: :shared 7167+ and :androidApp 534+ pass. :androidApp:assembleDebug builds, and CI "Android compile & tests" is green.
  • Emulator: CI "Android first-character baseline (emulator)" (18 suites) and "attachment authority" are green.
  • iOS: CI compiles and links the framework, builds the app, and runs the in-app XCTests (login router, adaptive editor, bridge selectors) on the simulator.
  • Manual emulator smoke on staging:
    • login and the home tabs, search;
    • opening a channel, sending, replying in a thread;
    • pickers, members, settings and release notes;
    • back, rotation, background and foreground.
  • Review: an independent three-way subagent review covered the Android host, the iOS host and the shared code. All CONFIRMED and PLAUSIBLE findings were fixed in this PR, except the accepted items below.

Known follow-ups (TODO(cmp-switch))

  • iOS swipe-back is edge-only until shared code reports horizontal-scroll consumption.
  • The iOS interactive keyboard dismiss does nothing.
  • On iOS, adaptive navigation "prepare" always rejects (editor_drift).
  • Chip padding no longer takes horizontal space; the link underline loses its custom thickness.
  • Splash hides on the first drawn frame.
  • No real iOS device or simulator walkthrough by a human yet.
  • Later: a scripted Slock→Raft rename, then the compose-resources migration that drops resource-core.

🤖 Generated with Claude Code


View in Stamp

Lagoon from raft opened this pull request through Stamp

Lagoon and others added 30 commits October 19, 2026 15:15
Move the verifyCoreHasNoKuikly task body into buildSrc as
registerNoKuiklyGuard() so the upcoming Compose Multiplatform UI module can
register the same source + resolved-graph check. :core keeps its task name,
configurations and wiring (check, preBuild, compileKotlin*).

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
New :ui-cmp module (Android + iOS, CMP 1.8.2) on top of :core and
:designsystem, guarded by verifyUiCmpHasNoKuikly:

- SlockCmpPages/SlockCmpRouter: the single migrated-pages allowlist that
  decides Kuikly vs CMP per page name. Product list is empty; debug builds
  admit compose_cmp_hello. Each page carries a rollback flag key and a
  SlockCmpRouteOverride seam (none installed: code default decides).
- SlockCmpRoot: RaftTheme, LocalSlockPageArgs (page name + unchanged
  pageData JSON) and LocalSlockHost (HostCapabilities + host navigator).
- BridgeWireHostCapabilities: HostCapabilities over the HRBridgeModule wire
  format (same method names and Kuikly-compatible params JSON as
  BridgeModule), so both native bridges serve CMP pages unchanged.
- SlockCmpIos: ComposeUIViewController factory and the Swift/ObjC-facing
  bridge and navigator protocols.
- compose_cmp_hello: debug demo page (theme, args, toast, close).

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
- SlockCmpActivity (ComponentActivity): renders SlockCmpRoot for the
  pageName/pageData extras SlockKuiklyActivity uses; edge to edge with
  adjustResize, system-bar icons follow the resolved Raft theme, system
  back and closePage finish the Activity, openPage goes back through
  SlockKuiklyActivity.start.
- Router split: slockKuiklyHostActivityClass picks SlockCmpActivity for
  pages SlockCmpRouter routes to CMP, so SlockKuiklyActivity.start (the
  Kuikly router adapter, MainActivity, notifications) needs no change.
  Intents naming SlockKuiklyActivity directly (diagnostics deep-link alias,
  tests) are forwarded once routed. SlockApplication configures the router
  (debug builds admit the debug-only demo page).
- HostCapabilities: BridgeWireHostCapabilities over a SlockKuiklyBridgeModule
  instance, called on the main thread like the Kuikly renderer does. The
  module takes an optional host Activity; Kuikly pages pass none, so their
  module still derives it from the render context.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
- :shared links :ui-cmp into the iOS framework and exports it (the app
  keeps a single Kotlin framework); :core stays unexported.
- SlockPageControllerFactory (Swift): the iOS router split. KRRouterHandler,
  HRBridgeModule openPage and the SwiftUI root ask it for a page; routed
  pages get SlockCmpIos.makeViewController (ComposeUIViewController) with
  the unchanged pageName/pageData, everything else the same
  KuiklyRenderViewController as before. Configured at launch (DEBUG admits
  the debug-only demo page).
- SlockCmpPageNavigator: push/present and pop/dismiss like KRRouterHandler.
- SlockCmpBridgeHost (ObjC): HostCapabilities transport that forwards each
  call to a private HRBridgeModule with the Kuikly module argument shape.
- Compose resources: compose.ios.resources.sync stays false; the Podfile
  script phase now also runs :ui-cmp:syncComposeResourcesForXcodeApp, which
  copies the prepared resources of :designsystem and :ui-cmp (Raft fonts) to
  <App>.app/compose-resources/composeResources/<package>/, the path the CMP
  runtime reads. The committed CocoaPods phase in project.pbxproj matches.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…cans

- test.yml: run :ui-cmp:testDebugUnitTest, compile it for
  iosSimulatorArm64, upload its reports, and add :designsystem/:ui-cmp
  sources and build scripts to the build-output cache key and paths
  (androidApp now depends on both, so a change there must miss the cache).
- plan_test_tier.sh: ui-cmp iosMain changes keep the iOS compile on main.
- check_auth_session_writes.sh and i18n_code_literal_scan.py include the
  ui-cmp source roots.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…lag forwarding, map-compat scan, host gap doc

- iOS: CMP controllers hide the system nav bar and allow full-screen
  swipe-back, matching KuiklyRenderViewController under the FD swizzle.
- Android: forward NEW_TASK/NEW_DOCUMENT/MULTIPLE_TASK when redirecting a
  routed page from SlockKuiklyActivity to SlockCmpActivity.
- check_commonmain_map_compat.sh also scans ui-cmp commonMain.
- Document the Android capability gap for SlockCmpActivity hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
… instead of Kuikly

Phase A of the CMP big switch. :shared drops the Kuikly compose fork,
Kuikly core/core-annotations, core-render-android, the Kuikly KSP page
processor and mermaid-kuikly, and depends on CMP 1.8.2 (ui, foundation,
material3, components-resources, ui-backhandler), JetBrains lifecycle
2.8.4, Coil 3.2.0, :designsystem and :ui-cmp (now api on every target).
compileSdk moves to 35 as upstream Compose 1.8 requires.

NetworkKMM, resource-core and the KuiklyBase datetime stay. The branch
is expected not to compile until the per-file fixes land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
scripts/cmp_migrate_imports.py rewrites every com.tencent.kuikly.* name
in :shared, :androidApp and the vendored markdown sources: the forked
compose packages to androidx.compose.*, Kuikly-only symbols and Kuikly
core (Pager, Module, nvi JSON, @page) to the build.raft.app.compat
layer under their existing simple names, Kuikly lifecycle to JetBrains
androidx.lifecycle. It is idempotent so it can be re-run after rebases;
--check reports pending changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…p and markdown

Output of scripts/cmp_migrate_imports.py; no hand edits. Remaining
com.tencent.kuikly.core.base / core.views references are the native-view
DSL and are migrated by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…nd Size

core.views.PlayState / VideoPlayControl and core.base.BorderStyle go to
the compat layer; core.base.Size (markdown image sizes) maps to
androidx.compose.ui.geometry.Size, which has the same width/height shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Keeps the Kuikly API surface :shared pages use, under the same simple
names, on top of upstream Compose and the CMP hosts, so the per-file
fixes stay small:

- compat.json: JSONObject / JSONArray / JSONException with Kuikly's
  coercions and byte-identical toString (via toSlockJsonString).
- compat.pager: Pager / ComposeContainer (setContent member), PageData
  (params + insets filled from WindowInsets), @page, IPagerEventObserver,
  IModuleCreator, PagerManager, BridgeManager, IPagerId/pagerId,
  LocalActivity (getPager(), pageData), urlParams.
- compat.module: Module (toNative over SlockCmpHost.bridgeTransport,
  main-thread callbacks), RouterModule (host navigator), CodecModule
  (sha256 in Kotlin), PerformanceModule (null data), PerformanceData.
- compat.ui: KuiklyFont (Android res/font, iOS SystemFont),
  BackHandler (CMP ui-backhandler), keyboardHeightChange (WindowInsets.ime),
  nestedScroll(NestedScrollMode), rememberAsyncImageBitmap (Coil),
  DrawableResource/imageResource, TextInputState, BorderStyle, PlayState,
  VideoPlayControl; setProp/bouncesEnable/scrollToTop/
  keyboardDismissModeInteractiveIOS are documented no-ops.

Supporting changes: SlockCmpHost gains an optional bridgeTransport (both
hosts pass theirs); BasePager drops Kuikly snapshot/thread verification;
the bundled TTFs move from androidApp to :shared res/font so KuiklyFont
can resolve them; :shared compile tasks depend on the SQLDelight
generator directly (the Kuikly KSP tasks used to carry that edge) and
compose resources get a package. Also includes the reference-rewrite
output for the PlayState/VideoPlayControl/BorderStyle/Size rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
scripts/cmp_generate_page_registry.py replaces the Kuikly KSP page
processor: it reads each @page("name") class (resolving const page-name
constants) and writes pageName -> factory maps, one for commonMain and
one for the gated visual fixtures (src/visualFixturesOff holds the empty
stand-in for builds without -PslockVisualFixtures).

RaftPageRegistry exposes pageName -> @composable for those pages, and
RaftPagerHost runs a page inside SlockCmpRoot: creates the Pager with
the unchanged pageData, calls willInit/created, fills PageData insets,
maps lifecycle resume/pause (and an `active` flag for child pages) to
viewDidAppear/viewDidDisappear, and provides LocalActivity and
LocalSlockPageArgs. :ui-cmp gets a RaftPageProvider seam
(SlockCmpRouter.installPageProvider) that SlockCmpRoot falls back to;
hosts do not install it yet and routing (rendersOnCmp) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
- compat.ui: pull-to-refresh stand-in (PullToRefreshState,
  rememberPullToRefreshState, pullToRefreshItem; indicator only while
  refreshing, no gesture), PointerEvent.nativeEvent (always null),
  withKuiklyIdleContext(pagerId, block) with Kuikly's signature.
- Android jvmTarget 11 for :shared (Coil 3 inline functions are JVM 11).
- Drop compose components-resources from :shared: it has no compose
  resources and the generator's source wiring breaks under the
  afterEvaluate setSrcDirs.
- Page registry generator skips the compat package and comments (a KDoc
  example was registered as a page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Foundational files every page group depends on:

- SlockLucideIcon: TEMPORARY stub with the same API (rounded outline in
  the icon colour, rotation/spin kept); the ImageVector port lands
  separately.
- SlockNativePixelAvatar / SlockNativeTiltedBadgeChrome: drawn with
  Canvas instead of native Kuikly views.
- SlockSandboxWebView: placeholder box (TODO(cmp-switch) AndroidView /
  UIKitView); controller, navigation state and API unchanged.
- SlockKuiklyChildPage: composes the child page inline through
  RaftPagerHost (active -> appear/disappear), same pageData contract.
- SlockTabBar: native floating tab bars fall back to the Compose dock.
- BasicWidget: square/themed corners without setProp, Modal on upstream
  Dialog (no ref/scrimColor), text fields without native props, the
  native editing-state reader reads the Compose TextFieldValue, Hover
  uses stickyHeader; TabPager keepItemAlive -> beyondViewportPageCount.
- Drop Kuikly's beyondBoundsItemCount list argument (12 call sites),
  stale base.bridgeModule imports and the Android native dispatch
  capture; key-event extension imports in AuthPages.
- SlockInProcessNotifyModule (a Kuikly render module) moves to the
  androidApp Kuikly host it belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
… on CMP

- SettingsHomePage: replace Kuikly MoveableDrawer with a new Compose
  RaftMoveableDrawer (push-style drawer, same open/close/progress/isOpen
  API, horizontal drag gated by userScrollEnabled, drawer content kept
  composed like the Kuikly pager page); scrim DivView node becomes a plain
  Box with the existing clickable; SelectionContainer without Kuikly state;
  drop com.tencent.kuikly.core imports.
- ThreadPage: SelectableText -> SelectionContainer { Text }, import the
  Modifier testTag (the semantics property was imported instead).
- ThreadComposerPage: Kuikly ModalBottomSheet(visible, dismissOnDrag) ->
  M3 ModalBottomSheet with sheet state (no drag handle, no insets,
  transparent rectangle container) shown while detailsVisible.
- Visual fixtures: drop the SelectableText import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
BasicWidget TextField (shared file, additive change): the previously
ignored imeActionRequested / Enter routing props are expressed in Compose.
- New raftResolvedKeyboardOptions: a multi-line field that did not request
  an IME action falls back to ImeAction.Default (newline key), matching the
  Kuikly text area line-mode contract.
- New Modifier.raftImeEnterAction: when imeAction is Send, hardware Enter
  sends on key-up and Shift+Enter inserts a newline (multi-line), porting
  SlockKuiklyActivity.handleSlockTextFieldEnterKey. Soft-keyboard Send
  keeps going through KeyboardActions.onSend.
TODO(cmp-switch): systemNewlineAction ("Insert newline" selection-menu
item) still ignored; iOS soft Send on multi-line fields unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Rewrite compat RaftPullToRefreshCompat.kt (owned by this group): the
Kuikly-named PullToRefreshState now wraps a material3 pull-to-refresh
state; new Modifier.raftPullToRefresh(state, enabled) applies material3
Modifier.pullToRefresh (the PullToRefreshBox engine) to the list, and
pullToRefreshItem registers onRefresh/threshold and grows with the pull
distance so the existing Slock indicator renders from pullProgress.
Wire the modifier at every call site: activity inbox (+ state body), home
list, saved panel (SettingsHomePage), members list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
RaftVideoPlayer (expect/actual) replaces Kuikly's native VideoView:
- Android: Media3 ExoPlayer + PlayerView in AndroidView, ported from
  androidApp SlockKRVideoViewAdapter (progress contract/attempt gate,
  native controller, fullscreen + back handling).
- iOS: AVPlayer in an embedded AVPlayerViewController via
  UIKitViewController, ported from SlockKRVideoViewHandler.m (playback
  audio session, end/failed/stalled notifications; status and time are
  polled every 500ms instead of KVO).
Keeps the VideoPlayControl/PlayState control surface; seeks are issued
once per request id.

RaftViewCapture replaces nativeRef + toImage(FILE): Modifier.raftCaptureRef
records the subtree into a GraphicsLayer; captureToPngFile() encodes the
toImageBitmap() result as PNG into an app-cache/tmp file and returns a
file:// ref (admitted by the existing temporary-file retirement).

Outside group ownership: shared/build.gradle.kts androidMain adds
media3-exoplayer, media3-ui, core-ktx and activity-compose (additive).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
SlockFilePreviewKuiklyVideo now renders RaftVideoPlayer (also used by the
inline thread video and hidden inline audio players), replacing
MakeKuiklyComposeNode<VideoView> and the seekToTimeMs setProp. The image
editor captures through Modifier.raftCaptureRef/captureToPngFile instead of
nativeRef/ViewRef/toImage; the capture runs NonCancellable so a late file
still reaches the operation-gate cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…stream Compose

Kuikly's SpanStyle carried inlineBoxStyle and textDecoration
color/thickness/offset; upstream SpanStyle has none of them.

- RaftInlineBoxStyle / RaftInlineUnderlineStyle travel as string
  annotations over the boxed range; Modifier.raftInlineDecorations
  paints them behind the text from the TextLayoutResult (per line
  segment, rounded fill + border, coloured underline).
- SlockMarkdownTextProvider.RichText applies the painter; inline code
  (vendored annotator), rich-text tag chips / ordinary-mention underline
  and message-permalink chips emit the annotations.
- Pressed tag/permalink states use a span background instead of the
  Kuikly box swap (TODO(cmp-switch)).
- Placeholder(...) now passes PlaceholderVerticalAlign (TextCenter for
  icons/spacers, AboveBaseline for markdown images).

TODO(cmp-switch): chip padding/margins no longer reserve advance width,
vertical box extent is estimated from the span font size, and markdown
link underline thickness/offset is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
mermaid-kuikly (MermaidKuiklyRenderer on a Kuikly CanvasView) is gone.
RaftMermaidCanvas ports it to a Compose Canvas over the same
mermaid-layout LayoutScene: rects (rounded), ellipses (fill opacity),
lines/polylines (dashed 6/4), polygons (linear gradient), text anchored
at the alphabetic baseline, #rgb/#rgba/#rrggbb/#rrggbbaa colours.

SlockMermaidDiagramBlock is now a plain commonMain function; the
identical android/ios actuals are deleted. Build: the mermaid-core /
layout-api / layout-simple deps move from androidMain + iosMain to
commonMain (KMP artifacts publish common metadata) — shared/build.gradle.kts
is touched for this, outside the markdown ownership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
# Conflicts:
#	compose/shared/build.gradle.kts
…and sync bridge calls

- SlockCmpRouter: the allowlist becomes "every registered page" (the :ui-cmp
  debug pages admitted in this build plus everything the installed
  RaftPageProvider renders). The per-page rollback seam (SlockCmpRouteOverride,
  rollbackFlagKey, enabledByDefault) is removed: there is no Kuikly host left
  to roll back to. configure(debugBuild) / rendersOnCmp / page keep their
  signatures (iOS SlockCmpIos unchanged).
- SlockCmpHost gains RaftHostEvents (native host -> page events, the Kuikly
  sendEvent channel: window insets, adaptive panes); HostBridgeTransport gains
  callSync (default null) for Kuikly syncCall bridge methods.
- :shared compat (additive, shared files): RaftPagerHost forwards host events
  to its page as pager events; Module.toNative(syncCall = true) answers from
  HostBridgeTransport.callSync when the host serves the method (falls back to
  the previous async + empty value otherwise). RaftPageRegistry.install doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Coil 3.2 had no network fetcher, so every remote image (avatars, attachment
thumbnails, rememberAsyncImageBitmap / imageResource) stayed blank after the
Kuikly removal. Add the Raft singleton ImageLoader:

- RaftImageSource mapper/keyer/fetchers: http(s) over SharedBinaryDownloader
  (single flight per content key, Coil disk cache 128MB + memory cache),
  assets:// bundled assets (Android APK assets; iOS main bundle, MR bundle,
  asset catalog, ported from KuiklyRenderComponentExpandHandler), inline
  <svg>, and slock-avatar-render-v1 managed avatar models read through a
  host hook (RaftImageHost.managedImageReader, registerDecoded + lease close
  via RaftManagedImageCommitInterceptor).
- task #403 keys: url = slockAvatarContentIdentity fingerprint, managed =
  nativeBytesKey; commit generation validated (newer commit evicts the
  decoded entry) instead of being part of the key.
- coil-svg 3.2.0 for SVG decoding; androidx.webkit added to androidMain
  for the upcoming sandbox WebView (build.gradle.kts).

Shared compat (additive): rememberAsyncImageBitmap loads via the Raft
loader at original size; RaftPagerHost / RaftPageRegistry.install() call
raftInstallImageLoader().

TODO(cmp-switch): hosts must install RaftImageHost.managedImageReader;
the #403 contentKey wire field (url<->managed sharing) is not emitted yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…witch, group 7)

SlockSandboxWebView was a placeholder box. It now renders through the new
expect RaftSandboxWebViewHost with the same parameters, controller commands
and events, so SettingsStandaloneWebViewHost, InAppBrowserPage, release
notes, server setup and the HTML file preview work unchanged.

- Android: RaftAndroidSandboxWebView ports androidApp
  SlockSandboxWebViewRenderView (settings, navigation fence, document-start
  auth script + bootstrap fallback, raftHostPort WebMessageListener bridge,
  runtime JS gating, desktop initial scale, renderer crash recovery, file
  chooser via RaftWebViewFileChooserHost, host main-document hook).
- iOS: RaftIosSandboxWebView ports iosApp SlockSandboxWebView.m (WKWebView
  rebuild on configuration attrs, ephemeral data store, user scripts, host
  bridge with origin/document checks, KVO navigation state, pageZoom
  desktop fit, content-process recovery, loadCommit).
- Host message kind mapping moved to raftDecodeWebViewHostMessage (common).

TODO(cmp-switch): Android host must implement RaftWebViewFileChooserHost and
set RaftAndroidWebViewHooks.mainDocumentProvider (release notes cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Kotlin/Native 2.1 exposes observeValueForKeyPath only as an NSObject
category extension, so it cannot be overridden (cross-compiled check:
"Unresolved reference NSKeyValueObservingProtocol"). Replace the KVO of
the .m port with a 250ms NSTimer poll while a WebView is attached;
navigation state callbacks stay de-duplicated. TODO(cmp-switch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Lagoon and others added 13 commits October 19, 2026 21:08
handleKeyboardSend gated on fieldValue.composition for every onSend, so
with Gboard keeping the Latin word in composition the soft Send key did
nothing. Composition gating now applies only to a hardware Enter, which
raftImeEnterAction marks by invoking onSend with the (renamed, now
internal) RaftHardwareEnterKeyboardActionScope receiver; the soft Send
key always sends and the live Send snapshot commits the composition.

Shared BasicWidget change: RaftSendKeyboardActionScope (private) becomes
internal RaftHardwareEnterKeyboardActionScope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
The 250ms navigation-state poll (the KVO stand-in) kept running when the page
was covered by another one. The container now stops the timer when it leaves
the window and restarts it (with one catch-up read) when it returns; a WebView
built while detached starts polling on attach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
raftImeEnterAction decided on key-up from the Shift state at that moment,
so releasing Shift slightly before Enter sent the message. The decision is
now recorded on key-down per field (RaftHardwareEnterRouter) and the
key-up only carries it out; a key-up without a matching key-down does
nothing.

Shared BasicWidget change: raftImeEnterAction becomes @composable (it
remembers the router); new internal RaftHardwareEnterRouter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…IGINAL

rememberAsyncImageBitmap decoded every model at the original size; the
image preview surface used it per thread row / file / composer preview,
so each thumbnail decoded a full-resolution bitmap (OOM risk). The Kuikly
adapter downsampled to the screen.

- rememberAsyncImageBitmap(model) now bounds the longer edge to the
  window's long edge (FIT, INEXACT sampling); new overload with an
  explicit maxDecodeEdgePx; RAFT_IMAGE_DECODE_ORIGINAL is an explicit
  opt-in used only by imageResource (bundled atlases drawn by sub-rect).
- The preview surface only reads the decoded dimensions (render
  identity), so it decodes a 64px subsampled copy.
  TODO(cmp-switch): header-only dimension read.

Compat layer change (RaftUiCompat: additive overload/helpers; default
decode bound changed) and shared BasicWidget call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…clear on logout

- The network fetcher only commits bytes that sniff as an image
  (raftSniffImageMimeType); a 2xx captive-portal / proxy HTML body is no
  longer pinned on disk under the image's key.
- Disk metadata now records the store time; entries of URLs that are not
  raft immutable avatar paths (Gravatar, external URLs) expire after 24h
  and are fetched again (the expired entry is still served if the
  refetch fails). Legacy metadata-only entries revalidate.
- raftClearImageCaches() drops the Coil memory + disk caches; the auth
  data source calls it next to the avatar owner retirement on logout,
  session clear and account replacement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
RaftVideoPlayer kept video/audio playing after the app was backgrounded.
Android: the player observes the host LifecycleOwner and pauses on
ON_STOP (not ON_PAUSE, so a visible multi-window Activity keeps playing,
per Android media guidance). iOS: the controller pauses on
UIApplicationDidEnterBackgroundNotification. The applied play control is
left unchanged, so returning does not auto-resume; PAUSED is reported
through the usual state callbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Inactive app-shell tabs stay composed (raftPlacedOnlyWhenActive), so their
BackHandlers stayed registered and, registered after the shell root
handler, won: open a filter menu on Tasks, switch to Members, press Back
and the invisible Tasks menu closed. RaftPagerHost now provides
LocalRaftPageActive (ANDed with the ancestor pages' value via
RaftProvidePageActive); the compat BackHandler and SlockBackHandler AND it
into `enabled`.

Compat layer change: additive (LocalRaftPageActive, RaftProvidePageActive,
raftBackHandlerEnabled) plus BackHandler honouring it; RaftPagerHost
provides it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Module.liveCallbacks was never cleared on page destroy and the callback
scope is global, so a late native result ran the callback of a destroyed
page (which could openPage/closePage on a finished Activity).
PagerManager.unregister (after PAGER_EVENT_WILL_DESTROY, so destroy-time
cleanup calls still go out) now detaches the page's modules: pending
callbacks are dropped and later calls are still sent but never call back.

Compat layer change (Module.detach, Pager.detachModules, unregister).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
getPager returned pagers.values.lastOrNull() for an unknown id, routing a
destroyed page's bridge calls to another Activity's page. Explicit ids now
resolve only to their own live page (getPager throws, new getPagerOrNull
returns null). All callers pass the id of their own LocalActivity pager,
which is registered for the whole composition (child effects are
disposed before RaftPagerHost unregisters the page).

Compat layer change (RaftPagerCompat PagerManager).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…entHub

Every Android app-scope event (multi-MiB realtime batches included) was
serialized to JSON on the main thread and published to
SlockKuiklyEventHub, whose only listener (the Kuikly render view bridge)
was deleted with the CMP switch; no production code registers a listener.
The Android app-scope bus is now SlockEnhancedNotifyCenter, in-process
only, like js/native. SlockKuiklyEventHub, the dispatcher and its
transport test are removed; installAndroidAppScopeEventBus stays as a
no-op for its call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
…mits

RaftMermaidCanvas sized itself scene size * zoom without bound; a huge
diagram or zoom produced a node size Compose cannot encode in Constraints
(throws during layout). The draw scale is now clamped so both sides stay
within 32,000 px (always representable).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
The Kuikly-era SlockSandboxWebViewRenderViewTest was deleted with the
Kuikly WebView adapter (0006940). Port its exact-main-document test to
raftIsExactMainDocumentUrl and cover raftDocumentStartScriptOriginRule /
raftInjectedJavaScriptBootstrapBaseUrl and
RaftWebViewBootstrapNavigationFence, which the CMP sandbox WebView uses to
fence the native bridge and the bootstrap document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
@stamp-build stamp-build Bot changed the title [WIP] feat(cmp): big switch — whole app UI from Kuikly to Compose Multiplatform feat(cmp): big switch — whole app UI from Kuikly to Compose Multiplatform Oct 19, 2026
… pop and the two-argument host-hooks install

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
@stamp-build
stamp-build Bot merged commit 12cbc90 into main Oct 20, 2026
28 of 34 checks passed
stamp-build Bot pushed a commit that referenced this pull request Oct 20, 2026
Re-targets #3184 onto release/1.14.2, where the gate still ships
(main deleted it with the Kuikly→Compose switch, #3192).

- `ssl.SSLError` is not a subclass of `urllib.error.URLError`/`ConnectionError`,
  so it escaped the `get_bytes` retry ladder and surfaced a CDN concurrency
  flake (UNEXPECTED_EOF_WHILE_READING) as a hard CONFLICT — a transport blip
  reported as a release incident. It is now retried like the others.
- Stale-closure failures said 'unexpected coordinates'/'closure missing' when
  the closure was merely pinned to an older release; the error now names the
  release the closure is on.

Pure refactor of the request open into `_fetch_once` keeps UA, _NoRedirect and
the 60s timeout identical. Self-test green on all arms, including
ssl-retry-recovers and ssl-retry-exhausted.

Co-authored-by: Mahua <mahua@mail.build>
stamp-build Bot pushed a commit that referenced this pull request Oct 21, 2026
…nversation

Bug A (QA 11500008, release blocker): in two-pane, once the detail pane showed
a conversation (cold-start-wide default landing, or a detail carried across a
width change), tapping another conversation in the Home or Activity list moved
the list highlight and the workspace selection, but the detail kept rendering
the old channel until the activity was paused/resumed.

Root cause (CMP switch #3192, not #3353): RaftPagerHost creates a new pager
when its args change (remember(args)) but invoked the new pager's content in
the old pager's composition slot. ThreadPageScreen reads payload, instanceId
and the adaptive close request into unkeyed remember, so the new page kept the
previous page's values: no new effect_enter, no READY. Kuikly gave every page
its own render view; key the page composition by pager to restore that.

- RaftPagerHost: key(pager) around the page content.
- Shell trace adaptive_trace side=shell hop=detail_mount at the hand-off from
  selection to detail page; a mount without a matching detail effect_enter
  now shows a stale detail directly in the log.
- Tests: RaftPagerHostPageSwapTest (Robolectric; red before the fix with
  rendered=[channel-all, channel-all]); AdaptiveDetailNavigationTest covers
  landing / width-change / user-opened detail -> tap replaces it, and #3353's
  landing still drops on a compact window.

Verified on emulator (wm size 1920x1200 <-> 1200x1920, alpha build, staging):
cold-start-wide landing then list tap, portrait->wide with detail then tap,
Activity list tap, and landing->compact shows the Home list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
stamp-build Bot added a commit that referenced this pull request Oct 21, 2026
…nversation (#3380)

Bug A (QA 11500008, release blocker): in two-pane, once the detail pane showed
a conversation (cold-start-wide default landing, or a detail carried across a
width change), tapping another conversation in the Home or Activity list moved
the list highlight and the workspace selection, but the detail kept rendering
the old channel until the activity was paused/resumed.

Root cause (CMP switch #3192, not #3353): RaftPagerHost creates a new pager
when its args change (remember(args)) but invoked the new pager's content in
the old pager's composition slot. ThreadPageScreen reads payload, instanceId
and the adaptive close request into unkeyed remember, so the new page kept the
previous page's values: no new effect_enter, no READY. Kuikly gave every page
its own render view; key the page composition by pager to restore that.

- RaftPagerHost: key(pager) around the page content.
- Shell trace adaptive_trace side=shell hop=detail_mount at the hand-off from
  selection to detail page; a mount without a matching detail effect_enter
  now shows a stale detail directly in the log.
- Tests: RaftPagerHostPageSwapTest (Robolectric; red before the fix with
  rendered=[channel-all, channel-all]); AdaptiveDetailNavigationTest covers
  landing / width-change / user-opened detail -> tap replaces it, and #3353's
  landing still drops on a compact window.

Verified on emulator (wm size 1920x1200 <-> 1200x1920, alpha build, staging):
cold-start-wide landing then list tap, portrait->wide with detail then tap,
Activity list tap, and landing->compact shows the Home list.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-authored-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
stamp-build Bot pushed a commit that referenced this pull request Oct 23, 2026
… zero tests

Task #18 CI gaps, from the mobile test audit (sections 4 and 5).

- test.yml iOS job: drop the "native image loader and media audio
  session" step (both XCTest classes were deleted in #3192; it executed
  0 tests and passed). Run the whole iosAppTests bundle instead of three
  classes, and fail unless every class ran (assert_xctest_ran.py, with a
  self-test). Run shared iosTest (*IosTest, *IosSmokeTest) on the
  simulator and fail on zero cases.
- New iOS behaviour tests: RaftSandboxWebViewNavigationFenceIosTest
  (the iOS fence; counterpart of the Android Robolectric test from #3565)
  and an adaptiveNavigation: bridge-method check in
  RaftCmpBridgeSelectorTests.
- ios_deterministic_repro.test.mjs runs the real repro script with a
  fake iOS driver and checks it taps only the QA server row and never
  falls back to another row.
- Android device suite: promote AndroidAdaptivePaneHostConnectedTest,
  QaSessionBootstrapInboxConnectedTest and
  RaftComposedCharacterBoundaryRuntimeTest; document two more pointer
  harnesses as manual; delete RaftMermaidVisualCaptureConnectedTest (no
  assertions).
- Delete three UI tests nothing runs or can run (T258ConsumerUITests,
  IosUseInteractionTests, RaftAdaptivePaneRuntimeTests).
- buildSrc HangWatchdogTestListenerTest: fix for the Gradle 9 TestResult
  API and run it in the android job.
- Wire 13 script tests into scripts-selftest.yml / qa-infra-test.yml, and
  add the Lucide generated-icon up-to-date check (cached pinned checkout)
  to design-tokens.yml. qa-infra-test now also triggers on the two
  workflows stamp_report.test.mjs reads.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stamp-build Bot added a commit that referenced this pull request Oct 23, 2026
… zero tests (#3572)

* test(ci): run the tests nothing ran; fail iOS test steps that execute zero tests

Task #18 CI gaps, from the mobile test audit (sections 4 and 5).

- test.yml iOS job: drop the "native image loader and media audio
  session" step (both XCTest classes were deleted in #3192; it executed
  0 tests and passed). Run the whole iosAppTests bundle instead of three
  classes, and fail unless every class ran (assert_xctest_ran.py, with a
  self-test). Run shared iosTest (*IosTest, *IosSmokeTest) on the
  simulator and fail on zero cases.
- New iOS behaviour tests: RaftSandboxWebViewNavigationFenceIosTest
  (the iOS fence; counterpart of the Android Robolectric test from #3565)
  and an adaptiveNavigation: bridge-method check in
  RaftCmpBridgeSelectorTests.
- ios_deterministic_repro.test.mjs runs the real repro script with a
  fake iOS driver and checks it taps only the QA server row and never
  falls back to another row.
- Android device suite: promote AndroidAdaptivePaneHostConnectedTest,
  QaSessionBootstrapInboxConnectedTest and
  RaftComposedCharacterBoundaryRuntimeTest; document two more pointer
  harnesses as manual; delete RaftMermaidVisualCaptureConnectedTest (no
  assertions).
- Delete three UI tests nothing runs or can run (T258ConsumerUITests,
  IosUseInteractionTests, RaftAdaptivePaneRuntimeTests).
- buildSrc HangWatchdogTestListenerTest: fix for the Gradle 9 TestResult
  API and run it in the android job.
- Wire 13 script tests into scripts-selftest.yml / qa-infra-test.yml, and
  add the Lucide generated-icon up-to-date check (cached pinned checkout)
  to design-tokens.yml. qa-infra-test now also triggers on the two
  workflows stamp_report.test.mjs reads.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ios,scripts): drive the iOS fence by URL; drop a cross-clock assertion

Kotlin/Native imports WKNavigationAction/WKFrameInfo properties as final,
so the fence test cannot stand an action in. decidePolicy now only reads
the action and hands the URL and frame kind to decideNavigationPolicy,
which holds the unchanged fence logic and is what the test drives.

test_run_android_visual_capture.sh compared the host runner's monotonic
clock with the fake device's (200 s), so it failed on any runner up for
less than 200 s. The device-window assertions stay.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ci): keep the suite self-test edit to the class counts

#3571 deletes the registry greps in this file; adding three more would
only conflict with it.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ci): run iosTest after the XCTests and print its failure messages

The first full run reached iosTest: 20 cases, 1 failure
(RaftSqlDatabaseIosSmokeTest file-backed migration rollback), reported
without a message. Print each failing case's message and trace from the
JUnit XML, upload the reports, and run the step last (and after an
XCTest failure) so one red suite does not hide the other.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(durable): treat thread pending-send objects as core in the shared migration check

The iOS smoke test (first run in CI by this PR) failed: after the v1->v9
migration the non-core schema snapshot also held threadPendingSendEntity
and its two indexes. #3092 added them to the Android instrumented copy
of this filter but not to the shared copy, which only the never-run iOS
test uses.

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-authored-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
stamp-build Bot added a commit that referenced this pull request Oct 23, 2026
…nd SVG data URIs (#3583)

Android visual capture has failed 12 cases on main since the Kuikly to
Compose Multiplatform switch (#3192) and the fixture alignment in #3231
(first full failing run 39440171589 at 86449d0; last full green run
39328865111 at a52d784).

- Pin menus and compact message menus: RaftModalAnchoredPopover renders in
  Modal, which on CMP is its own Dialog window. The long-press render poll
  and its before frame drew only the activity decor, so the open menu was
  invisible (changedRatio 0). They now use the fixture-owned window
  composite, the same one the final capture has used since #3231.
- Pending-mention batch partial/details and create-agent dialog-empty:
  findAccessibilityNodeInfosByText goes through the view's
  AccessibilityNodeProvider. Compose's provider does not implement by-text
  search, so the lookup found nothing even with "View" on screen. Walk the
  accessibility tree for an exact text match instead.
- File preview image actions (and the image editor canvas): the fixture
  image is a percent-encoded data:image/svg+xml URI. Coil's DataUriFetcher
  accepts only ;base64, and threw, so the preview fell back to its
  placeholder. raftImageSourceOf now decodes textual SVG data URIs into the
  existing InlineSvg source.
- Status dot states: #3231 aligned the fixture with the web case (four
  presence dots in 342x92, no pulse row), so a correct frame is 98.8%
  canvas and tripped the blank-frame proxy. The crop now declares its
  content (sticker ink plus four presence fills, each >= a 4dp square),
  which replaces the one-colour proxy for this case only and still fails a
  blank frame or a missing dot.

Co-authored-by: Lagoon <raft+lagoon@agents.stamp.build>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants