Skip to content

Draft: Cloud Sync V2 work in progress and build fixes - #2

Draft
Xare123 wants to merge 92 commits into
agent/latest-alpha-contact-profilefrom
agent/cloud-sync-v2-draft
Draft

Draft: Cloud Sync V2 work in progress and build fixes#2
Xare123 wants to merge 92 commits into
agent/latest-alpha-contact-profilefrom
agent/cloud-sync-v2-draft

Conversation

@Xare123

@Xare123 Xare123 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

This draft is a CI and review snapshot. It is not ready to merge. Commit history and upstream-facing scope still need to be split into reviewable PRs.

Current scope

This branch combines production-path fixes, Cloud Sync V2 safety scaffolding, Windows ARM64 enablement, Android native-library delivery checks, and FaceTime diagnostics/tests. It exists on the fork so the complete matrix can be exercised before any upstream proposal.

High-impact fixes

Android and Windows builds

  • Fixed CargoKit detection for Flutter 3.44.8's com.flutter.gradle.FlutterPlugin. Before this change, Android builds could report success while omitting librust_lib_bluebubbles.so.
  • Removed unused legacy TLS dependencies that pulled ring 0.16.20 and blocked Windows ARM64 Rust builds.
  • Corrected Windows ARM64 Flutter tooling setup and added PE machine-type verification.
  • Added APK assertions for Flutter, ObjectBox, and the Rust bridge.
  • Pinned CI downloads and moved Java setup to actions/setup-java@v5.

Existing CloudKit path

  • Reactions now resolve Apple's p:<part>/<guid>, bp:<part>/<guid>, and bare parent references instead of storing an orphaned raw value.
  • Uploads no longer emit invalid p:null/<guid> references.
  • Unknown reaction types at or above 2008 no longer throw RangeError.
  • Attachment GUIDs containing underscores are no longer truncated.
  • Empty CloudKit list wire values are preserved as semantic evidence instead of being fabricated as explicit clears.
  • The coordinator no longer treats its own interlock fence as a conflict.
  • Silent UniqueViolationException message-save failures are now observable.

Cloud Sync V2 safety

  • Added canonical DTO validation, semantic identity, monotonic merge, durable page/checkpoint handling, quarantine, and fail-closed recovery tests.
  • Added a read-only sampler Beta package with a separate application ID.
  • Added provenance, ownership, production-readiness, and Windows host documentation.

FaceTime reliability and diagnostics

  • Correlated admission approval with the verified media session instead of approving an unrelated or stale request.
  • Made failed admission attempts retryable while suppressing concurrent and completed duplicates.
  • Replaced malformed encrypted-packet and missing-session panics with fail-safe errors.
  • Added host-independent Android parsing for escaped WebView media evidence.
  • Hardened camera/microphone permission handling so denied, partial, mismatched, or unmapped requests cannot start media services.
  • Added focused coverage for admission, ICE/media evidence, lifecycle, controls, and parser edge cases.

Verified on head d88f4383d

Gate Result
Full Dart suite 454 passed
Main Rust suite 105 passed
rustpush suite 30 passed
Cloud Sync protector harness 24 passed
Android cold-start and JVM test task passed
Alpha profile/debug APK builds and native-library assertions passed
Beta sampler APK and ARM64 Rust bridge assertion passed
Windows x64 passed
Windows ARM64 passed
Sensitive Rust diagnostic scan passed
Native-media fail-closed scaffold passed

CI runs:

Downloaded Beta artifact verification:

  • Package: com.bluebubbles.messaging.beta
  • APK v2 signature: valid
  • SHA-256: 0C68FA27ACF275D49F012C3283EB0A9FD166D82DE6CAFE6395CB2963A1C56129
  • Contains lib/arm64-v8a/libflutter.so, libobjectbox-jni.so, and librust_lib_bluebubbles.so

Live validation still required

Automated tests cannot prove Apple's private production service will accept the full exchange. Before calling FaceTime fixed, test exactly one outgoing and one incoming call while recording these boundaries: WebView load, patch installation, permissions, correlated admission, ICE connected/completed, live remote audio/video tracks, and increasing media bytes. Stop at the first failed boundary instead of repeating blind calls.

Cloud Sync V2 remains gated on a read-only sampler run, reconciliation review, controlled test-account validation, and a soak. It must not be enabled against real message history yet.

Fork and upstream constraints

.gitmodules currently points at the Xare123/rustpush fork, whose nested apple-private-apis dependency also points at the fork. Those URLs and commit dependencies must be handled deliberately when the work is split for upstream review.

Companion branches:

  • Xare123/rustpush@agent/cloud-sync-v2-draft
  • Xare123/apple-private-apis@agent/cloud-sync-v2-draft

Android release signing is not configured. The validated artifacts are debug/profile test packages. The official ANGLE native-media build remains a separately gated licensing/provenance task.

Non-blocking CI warnings remain for actions/checkout@v4 and actions/upload-artifact@v4 being forced from Node 20 to Node 24, plus Android's deprecated onSurfaceDestroyed() callback. They should be handled in narrow follow-up changes rather than mixed into this draft.

Xare123 added 30 commits August 6, 2026 06:01
Draft snapshot so CI can build this work on runners that do not share this
development host's limits. History will be reorganised before this is proposed
for merge.

Build fixes in this snapshot:

CargoKit located Flutter's Gradle plugin by comparing the fully qualified class
name to "FlutterPlugin". Flutter 3.44.8 ships the Kotlin rewrite as
com.flutter.gradle.FlutterPlugin, so the comparison failed, CargoKit skipped
itself, and every Android package was built with no librust_lib_bluebubbles.so
while still reporting success. It now accepts a package-qualified name.

build_verified_alpha.ps1 required lib/arm64-v8a/libapp.so in every mode, but a
debug package carries interpreted Dart in its asset bundle. That entry is now
required only for profile and release.

verify_foundation.ps1 selected an objectbox.dll by presence alone and picked a
stale 4.0.2 library out of a previous build directory, which failed 63 Cloud
Sync tests with an unrelated LateInitializationError. It now derives the host
architecture from dart.exe, reads the pinned version from pubspec.yaml, and
refuses a library that does not match both.

check_sensitive_rust_logs.ps1 exited without setting a status on success, so a
passing scan aborted the verification gate in a fresh shell.

CloudSemanticIdentifierHasher moved to cloud_sync_semantic_identity.rs so the
protector and its dependency-light standalone harness can both link it without
the Apple record parsing stack. Hash domains are unchanged.

Correctness fixes in the CloudKit download path:

An unknown Apple reaction type at or above 2008 indexed past ReactionTypes and
threw a RangeError, aborting the whole message. Unknown types now yield no
reaction row.

Attachment owner parsing used split("_")[2], truncating any message GUID
containing an underscore and throwing on fewer than three segments. One tested
parser in lib/utils/attachment_guid_utils.dart now backs all five former copies
and Attachment.applyFromCloud.

.gitmodules resolves rustpush from the Xare123 fork for the same reason its own
.gitmodules points at the apple-private-apis fork: the icloud-auth rustls
removal that unblocks aarch64-pc-windows-msvc exists only there. Revert both
URLs before proposing this upstream.
…r SDK

Two CI lanes failed for reasons unrelated to the code under test.

The APK job ran the Cloud Sync suite with no ObjectBox C library on the runner,
so 63 ObjectBox-backed tests failed with "Failed to load dynamic library
'libobjectbox.so'". The new step downloads the release matching the objectbox
version pinned in pubspec.yaml, verifies a pinned SHA-256 because the upstream
download script is unpinned, and exports LD_LIBRARY_PATH. It also fails if the
pubspec pin and the installed version drift apart.

The Windows ARM64 job installed an x64 Flutter SDK and then tried to swap in an
ARM64 Dart SDK by calling Flutter's private bin\internal\update_dart_sdk.ps1.
Flutter 3.44.8 no longer ships that script, so the step failed with a
command-not-found before any build work. Flutter 3.44 publishes a native
Windows ARM64 Dart SDK and engine, so the setup action now requests the
architecture matching the runner. The ARM64 Dart and engine assertions are kept
so a silent fallback still fails the job.
The previous commit assumed Flutter publishes a native Windows ARM64 SDK
archive and asked the setup action for it. It does not: every entry in
releases_windows.json reports dart_sdk_arch x64, so the request failed during
setup.

The original swap approach was right and the real defect was a path. The step
resolved bin\internal\update_dart_sdk.ps1 against the action's cache-path
output, but the archive unpacks into a single top-level flutter directory, so
the script lives one level below that. FLUTTER_ROOT already points at the SDK
root, so the step now uses it and asserts the script exists before running,
turning a future layout change into a clear failure instead of
command-not-found.

Verified against the archive CI actually downloads: it contains
flutter/bin/internal/update_dart_sdk.ps1 and unpacks to a single flutter
directory.
The protected-data logging scan failed with "pwsh: command not found". It is a
.ps1 and ubuntu-latest has moved to an image that no longer ships PowerShell.
The failure was latent: before the ObjectBox fix the job never reached this
step.

Pin the APK and bindings jobs to ubuntu-24.04 so an image change cannot drift
the gates, which the production-readiness notes already ask for on the bindings
job. Also install pwsh when it is absent, guarded so an image that ships it
costs nothing, rather than reimplementing a security scan in shell where the two
copies could disagree.
…ly produces

Two more steps that had never executed, both exposed as earlier fixes unblocked
the pipeline.

The Android cold-start step ran ./gradlew and exited 127. android/.gitignore
excludes /gradlew and gradle-wrapper.jar and upstream tracks neither, so the
wrapper does not exist in a fresh checkout. Only gradle-wrapper.properties is
tracked, so install exactly the distribution it pins, cross-check the version
against that file so the two cannot drift, verify the archive against Gradle's
published SHA-256, and invoke gradle directly.

The ARM64 job asserted a windows-arm64-release engine directory after
"flutter precache --windows". precache fetches only the host debug engine;
profile and release engines download on demand during the build, so that
assertion could never pass. Confirmed by reproducing the whole sequence on an
ARM64 host: the x64 archive plus the Dart SDK swap plus precache yields
windows-arm64 next to the archive's x64 engines and no windows-arm64-release.
The check now requires a native ARM64 engine and reports which engines are
present when it fails, which is what the step was really guarding.
The ARM64 job failed with "Snapshot not compatible with the current VM
configuration: the snapshot requires ... x64 windows ... but the VM has ...
arm64 windows". flutter_tools.snapshot ships compiled for the archive's x64 Dart
VM, and the setup action's cached bin/cache carries a stamp that still matches
it, so nothing triggers a rebuild after the Dart SDK swap. Every later flutter
command then fails and no ARM64 engine is fetched, which is what the engine
assertion reported as "Present: windows-x64, windows-x64-profile,
windows-x64-release".

Removing the snapshot and its stamp makes the next flutter invocation recompile
the tool against the ARM64 VM. Verified on an ARM64 host: after deleting both,
flutter prints "Building flutter tool" and produces a 42,879,680-byte snapshot
where the shipped x64 one is 44,665,376, then runs normally.

The snapshot is a regenerable cache artifact, so the only cost when a rebuild
was unnecessary is the compile time.
The manual shadow sampler could never reach the network. The interlock acquires
a coordinator lease on a sentinel fence scope, writes it as a durable row, then
runs the guarded work while that row is live. The guarded work's first act is a
preflight whose lease probe read every unexpired lease in the box, found the
interlock's own fence, and threw coordinator_active. No network call, no report
file, every time.

The probe now ignores the fence scope key. A mutual-exclusion fence is not
another sync coordinator, and an operation must not observe itself as one. Every
other scope, including an unrecognized one, still blocks.

This was never caught because the probe and the interlock were only ever tested
apart. The probe's existing test inserts an arbitrary-scope lease and still
passes unchanged; two new tests cover the fence scope alone and a real
coordinator lease sitting alongside it.
Adds a developer-only beta APK built with the Cloud Sync V2 sampler compile
gate enabled, so the first live read-only run does not depend on a developer
workstation. The beta flavour is a separate applicationId and installs beside
alpha without touching its database.

Debug rather than release: the beta release config needs a signing keystore,
and the shadow report has no in-app export, so it must be pulled with
adb run-as, which a release build does not permit.

The APK is checked for lib/arm64-v8a/librust_lib_bluebubbles.so before upload.
CargoKit silently skipped the Rust build on Flutter 3.44.8 until the plugin
detection fix, producing packages that installed and could not work while the
build still reported success. This makes that failure mode loud.
The APK job now spends most of its time in CargoKit. Every flavour and build
type pays a separate Rust and vendored-OpenSSL compile, and the beta sampler
build sat at the end of that chain behind the full test suite and both alpha
APKs, so it was over an hour from being produced.

Moving it to its own job lets it build in parallel. Setup mirrors the APK job,
including the Fairplay fixtures the Rust build needs, which the first version of
this job omitted.

The added time in the APK job is left alone. It is the cost of those packages
containing a native library at all; before the CargoKit plugin-detection fix
they were uploaded without one.
The rollout plan describes phases and the gate documents describe conditions,
but nothing stated in order what is left and which parts cannot be finished by
writing code.

Separates the remainder into code work, work that requires live Apple access,
and work blocked by licensing or missing hardware. Records the measured state
as of today rather than repeating stale claims, and names the one open question
that would reorder the plan: whether messageUpdateZone and
recoverableMessageDeleteZone carry edits and recoverable deletes, which would
put zone coverage ahead of semantic apply.
Both parsers required a `p:<part>/<guid>` wrapper. Apple omits the wrapper
entirely when a reaction targets no particular part, so the value is a bare
GUID, and the converter turned that parse failure into a hard quarantine.
Every partless reaction was therefore dropped, with a MalformedParent reason
that reads as corrupt data rather than a parser gap.

A bare value must carry no structure of its own. The identifier validators only
reject empty, oversized, and NUL-bearing values, so without an explicit check a
malformed wrapper such as `0/<guid>` or `bp:0/<guid>` would be accepted whole
as the parent identifier.

The two parsers also disagreed on leading zeros: the native side rejected them
and Dart normalised them, so `p:0003/<guid>` converted on one side of the
bridge and quarantined on the other. Dart now rejects them too.

parent_part becomes optional through ParsedAssociatedParent and
CloudCanonicalParentReference. Tests that asserted the bare form was invalid,
and that `p:0003/` normalised to part 3, asserted the wrong grammar and are
corrected.
CloudKit distinguishes "present but empty" from a field being absent using its
own wire type, and the V2 merge contract depends on that distinction. The
presence extractor collapsed every valued field to PresentWithValue and never
read the type tag, so the evidence was destroyed at the transport boundary.

The practical consequence was that the first live fetch could not answer whether
Apple emits EMPTY_LIST at all, which is the question the tri-state exists to
settle. The prior art cannot answer it either: the vendored client encodes an
empty collection as an omitted field and decodes EMPTY_LIST identically to an
empty list, so it can neither emit nor detect the distinction.

Recorded as evidence only. No conversion decision reads it. Whether an empty
list means "clear" is exactly what a live run has to establish, and inferring a
meaning here would repeat the existing mistake of inferring one from an empty
message-summary dictionary.
A present-but-empty ec, ep, otr, or rp in the message summary was converted to
ExplicitClear, meaning "discard every edit" and "un-retract every part". Apple's
summary plist omits empty collections rather than sending them, so that shape
carries no instruction, and acting on it would wipe local edit history and
retraction state on a guess.

It also applied a weaker standard than the rest of this file. A record-level
clear requires an authoritative marker from a pre-typed decoder and errors with
ExplicitClearWithoutPresence otherwise, but the summary path inferred one
straight from dictionary key presence. Empty collections now read as Absent.

attributedBody is left alone: it is protobuf optional bytes, where
present-and-empty is genuinely distinguishable from absent, so a clear there is
well founded.

reaction_cannot_smuggle_edit_or_retraction_state was passing only because the
all-empty fixture produced that fabricated clear, so it never exercised smuggled
state. It now carries a real edit with a matching edited-part index, and still
quarantines.
sync_directory opened the directory read-only with backup semantics and called
sync_all, which issues FlushFileBuffers. That call requires write access and is
not supported on a directory handle, so on Windows it always failed and took
every enclosing store operation with it.

Eighteen of the lease, recovery, and garbage-collection tests failed on this
host for that single reason, each reporting an opaque Io because the underlying
error is discarded by map_err. With this fixed the cloud_sync Rust suite is
100 passed, 0 failed, where it was 81 passed, 19 failed.

Windows offers no directory-sync primitive, so a no-op is the honest
implementation rather than pretending to a guarantee the platform does not
provide. Durability of a rename on Windows rests on startup reconciliation,
which treats the database as authoritative and repairs the staging directory
against it.

This was pre-existing and unrelated to the canonical-converter work; it was
confirmed against a stashed tree before being changed.
The open-source review states the rule that every borrowed idea must be
recorded with its project, licence, whether code or only a concept was taken,
and the implementing file. It held a per-project table but none of those
entries. The ledger adds them, covering the protocol facts this work took from
the SSPL-licensed rustpush submodule.

The distinction the ledger turns on is that a field name, a wire type, a zone
name, and an identifier grammar are facts about Apple's protocol, while struct
definitions, derive macros, and .proto files are expression carrying their
project's licence. Every entry states which was taken. Two entries name no
implementing file because the fact prevented work rather than causing it.

The ObjectBox record exists because Cloud Sync V2 is about to make that store
the durability boundary for reconciled message data. It also corrects a claim
worth not inheriting: published warnings concern the archived community Rust
binding, and this repository has no Rust dependency on ObjectBox at all. The
real exposure is narrower, that the native library is proprietary while the
bindings are Apache-2.0, and it is already present rather than introduced here.

Both note that the pending redistribution review does not currently list
ObjectBox alongside libmpv and FFmpeg, and that the repository has a LICENSE but
no third-party notices file.
applyFromCloud stored proto1.associatedMessageGuid verbatim. Apple sends that
field as `p:<part>/<guid>`, so the stored value never matched Message_.guid,
and the lookup in Message.handleAssociatedMessages compares it directly against
that column. Every reaction arriving over CloudKit was therefore left
permanently orphaned from its parent.

The part was separately derived from the reaction's own attributed body by
matching the associated range. The canonical mapping forbids this: the range is
validation evidence, not the source of the part.

Both now come from CloudAssociatedMessageParentReference, which already handles
the wrapped form, the bare-GUID partless form Apple uses when no part is
targeted, and rejects structured values that are not either. That parser existed
and was tested but had no caller outside its own test.

An unparseable parent leaves the reaction unassociated rather than aborting the
download, matching the surrounding fail-open behaviour for unknown reaction
types.

Note the equivalent JSON path in fromMap already parsed this correctly, so the
two ingestion paths disagreed.
Field ownership is the prerequisite for the semantic adapter. Without it,
"the server wins" gets implemented field by field from memory, and the first
time that is wrong the symptom is a user's local state being silently
overwritten, discovered from a bug report rather than a test.

Classes are server-immutable, server-mutable, and device-owned, each with its
merge rule. The server-owned set was derived from what applyFromCloud actually
writes rather than from what the schema could carry. Two existing locks,
lockChatName and lockChatIcon, are called out as the one place a device
decision outranks server authority.

It also records why a per-row sequence number is unnecessary: the checkpoint,
inbox status, and projected rows already commit in one ObjectBox transaction,
so exactly-once follows from the transaction rather than a version column. The
residual risk is ordering within the server-mutable set, which the monotonic
rules cover, and field ownership already partitions by zone.

Separately, _advanceContiguousApplied stops at the first inbox row that is not
applied, so a quarantined row blocks the checkpoint permanently. That is
deliberate, but unbounded: the journal fills, fetching stops, and if the stall
outlasts the undocumented change-token lifetime the cost is a full zone
re-bootstrap. Both options are written down rather than defaulted into, since
this is dormant Phase 2 code and changing the safety model unilaterally would
be the wrong call.
Both new parsers rejected `bp:<part>/<guid>`. That is a real shape: this app's
own JSON ingestion path in Message.fromMap has always stripped the `bp:` prefix,
in both the io and html variants. Rejecting it was a regression introduced with
the bare-GUID work, and a worse failure than the bug it sat beside. Dart stored
the association as null, which is unrecoverable, and Rust quarantined the whole
record. Both parsers now accept it; `bpdi:`, the balloon payload reference,
stays rejected. Tests that asserted `bp:` must be rejected encoded the gap as
intended behaviour and are corrected.

The CI changes matter more than any single fix in this branch.

cargo check --lib does not compile #[cfg(test)] code, and it was the only cargo
invocation in any workflow. Every Rust test here was unenforced: the canonical
converter, the DTO parsers, the protected store, the identity hasher. The
100-passing figure quoted in recent commits was a local result that no build
could ever contradict. Both the library suite and the protector harness now run.

The Dart job enumerated individual test files, and that list had drifted badly:
eleven test files were executed by no workflow at all, including the only
coverage for the attachment-GUID parser and the reaction-type mapper, both
rewritten in this branch. It now runs the whole suite, ordered after the
ObjectBox install the Cloud Sync tests depend on.
Upload could not emit the partless parent. "p:$associatedMessagePart/..."
interpolated a null part into the literal "p:null/<guid>", a shape Apple never
sends and which both of this app's parsers now reject, so a round trip through
the uploader could not be read back. It emits the bare GUID instead.

Partless and part-zero parents are now pinned by a test rather than left
implicit. They share a parent hash, because the hash covers only the GUID, but
remain distinct payload values. That is the truthful representation: for a
single-part message the two may mean the same thing, but for a multi-part
message "the message" and "its first part" are different targets and nothing
available offline settles which Apple intends. Dart keeps the same distinction
in storage and only coerces when matching for display.

The EMPTY_LIST accessors stay unwired on purpose. Surfacing them means adding a
transient DTO field, and that DTO should be widened once and deliberately for
semantic apply rather than regenerated for one diagnostic. They are marked
pending, the name list is now sorted so a future report cannot vary between
runs, and carrying the observation across is recorded as a required item for
that widening.

The roadmap also carried my own error forward, claiming bp/bpdi handling should
be removed from the record layer. Corrected, with the reason: that finding was
about field names in the IDS payload and said nothing about the bp: prefix
inside associatedMessageGuid, which is a real shape.
actions/setup-java v1 through v4 are deprecated and the runner now warns
on every run. Bump both jobs to v5. The java-version and distribution
inputs are unchanged, so this is a version bump and nothing else; the JDK
is still Temurin 21.

actions-rs/toolchain is a harder problem than a warning. The actions-rs
organisation was archived in October 2023, so that action is pinned to a
Node runtime GitHub is actively retiring and will not be updated when it
breaks. bridge-bindings.yml already uses dtolnay/rust-toolchain, which is
the maintained replacement, so this only brings build.yml in line with a
choice this repository had already made. dtolnay encodes the toolchain in
the ref rather than an input, which is why the `with: toolchain: stable`
block goes away without losing the pin.
Message.save() and bulkSave() caught UniqueViolationException and
discarded it. save() is the one that matters: on that path `id` is left
null and the message is returned as though it had been persisted, so no
caller can distinguish a saved message from a dropped one. That is the
exact mechanism by which legacy and Cloud Sync V2 coexistence would lose
a message silently rather than loudly, and the first live sampler run
depends on its own report being truthful about what was written.

Control flow is unchanged, because callers rely on save() not throwing.
Only the silence goes away. replaceMessage already logs this same
constraint violation as an error a hundred lines below, so these two
sites were the outliers against the file's own convention rather than a
new policy being introduced here.

Verified: 394 Dart tests pass, analyzer reports no new issues.
I previously reported that conversion failures "retry forever with no
dead-letter path". Reading the driver rather than the catch block, that
was overstated. CloudMessageUploadBatchResult.madeProgress is false when
nothing converted in a pass, and rustpush_service breaks out of the
upload loop and logs how many messages stayed queued. A wholly
unconvertible backlog terminates; it does not spin.

The genuine gap is narrower and worth stating accurately. Nothing
persists the fact that a given message has failed conversion, so a
permanently unconvertible message is invisible across sessions, and a
pass that converts some messages but not others never trips the break, so
the stuck ones ride along unnoticed. Closing that needs a durable
per-message attempt count, which is a schema change and is not worth
making ahead of the first live run. The read-only sampler does not
exercise this direction at all.

Also records why this branch diverges from upstream here. Upstream sets
ckSyncState = true in a `finally`, so a message that fails to convert is
marked crawled and silently never reaches CloudKit. This branch keeps it
unsynced instead. That is a deliberate trade, not an accident, and a
reviewer would otherwise have to infer it from the diff.
MentionTextEditingController.notifyListeners() ran on every change to the
message field and logged both the previous and current full text. On the
test device this produced roughly a megabyte of log per day and wrote
every character typed into a message, including its final content, to a
plaintext file under app_flutter/logs.

Two costs, one of which is not obvious. The volume rotates real
diagnostics out of the retained logs, which is how it was found: the
composer chatter crowded out everything else while looking for the cause
of an unrelated bug. The other is that message text lands on disk in the
clear for anyone who can read the app's files.

The line is a leftover debug print, tagged "a", with no caller. The
Caret diff and Invalid changed annotations logs immediately below it are
deliberate and stay.
The earlier fix taught CargoKit to recognise Flutter 3.44.8's Kotlin
plugin, which it had been failing to find. That was correct as far as it
went, and it turned a silent skip into a hang: once CargoKit finds the
plugin it immediately calls Groovy-era APIs that the Kotlin rewrite no
longer has.

  plugin.project            -> now `private var project: Project? = null`
  plugin.getTargetPlatforms -> removed, moved to FlutterPluginUtils

Both are called from inside `applicationVariants.all`, so they throw once
per variant. This project has five flavours across two build types, so
Gradle collected roughly ten failures and handed them to
MultipleBuildFailuresExceptionAnalyser. DefaultFailureFactory then
recurses over the combined cause graph, constructing a Throwable and
filling in a stack trace at every node, and never returns. The build had
already failed; Gradle simply could not finish rendering the error.

That is why no output ever appeared. A cancelled CI run shows the last
log line at 17:35 and then a 302 minute gap of complete silence. Locally
it pins one core at 100% with no cargo or rustc process anywhere, and
three separate thread dumps land in the same frame.

The fix stops holding the plugin instance, which was only ever used to
reach its project, and resolves that project directly during the search.
Target platforms are read from the -Ptarget-platform property, which is
what FlutterPluginUtils.getTargetPlatforms does, with the same default
list when the property is absent.

`print` also becomes `println`, because the un-terminated line left the
skip message glued to whatever Gradle logged next.

Verified: :app:assembleAlphaDebug now completes in about 90 seconds
rather than hanging indefinitely, and the CargoKit task registers and
runs. It currently fails on a separate Windows path resolution error,
"The system cannot find the path specified", which is visible and does
not block the build. That is a different bug and is not fixed here.
The beta sampler APK installed and then hung on launch. The visible error
was a path_provider channel failure inside FilesystemService.init, which
names nothing about the real cause.

The real cause is two packages up the chain. irondash_engine_context and
super_native_extensions each vendor their own copy of CargoKit, and
neither can see Flutter 3.44.8's Kotlin plugin, so their Rust is never
built and their native libraries are absent from the APK. At startup
IrondashEngineContextPlugin's static initializer calls System.loadLibrary
and throws UnsatisfiedLinkError. That throw happens inside
GeneratedPluginRegistrant.registerWith, so registration aborts for EVERY
plugin, not just that one. path_provider is simply the first casualty
loud enough to notice.

Fixing the class-name match alone is not sufficient and is worse than
doing nothing: once CargoKit finds the Kotlin plugin it calls
plugin.project, now private, and plugin.getTargetPlatforms(), now moved
to FlutterPluginUtils. Both throw once per variant, and Gradle 8.9 wedges
indefinitely trying to format the resulting pile of failures. The patch
therefore also stops reaching through the plugin instance and resolves
the Flutter Project directly, matching the fix already applied to
rust_builder.

These copies live in the pub cache and are restored by pub get, so a
committed edit cannot reach them. The patch runs as a build step instead.
It is idempotent and re-running reports the files as already current.

The beta APK check now also asserts libirondash_engine_context_native.so
is present. Its absence does not fail any build; it produces a package
that installs cleanly and dies on launch, so only an explicit assertion
catches it.
GeneratedPluginRegistrant registers two CargoKit-backed plugins,
IrondashEngineContextPlugin and SuperNativeExtensionsPlugin, and the app
loads its own bridge on top of that. The previous assertion covered only
the first of those because it was written from the crash that happened to
surface first.

Checking the whole set matters more than usual here. A missing entry
fails no build at all. It produces an APK that installs cleanly and dies
on launch, because System.loadLibrary throws from a static initializer
inside GeneratedPluginRegistrant.registerWith and takes every other
plugin down with it, surfacing as an unrelated channel error.

libdartjni.so and libsqlite3.so are deliberately absent from this list.
Both turn up in a pub-cache-wide scan for System.loadLibrary, but jni and
sqlite3_flutter_libs are not dependencies of this app; they are unrelated
packages sharing the cache. pubspec.lock and the registrant are the
authority, not the cache.
The step failed with "Permission denied" and exit 126. The script was
committed from a Windows host, where git records mode 100644 regardless
of the local chmod, so the checked-out file was not executable.

Fixed twice over, deliberately. The mode is now 100755 in the index, and
the workflow invokes the script through `bash` rather than relying on the
exec bit. The second guard matters because this repository is developed
on Windows: any future edit that recreates the file will silently drop
the mode again, and a build step whose only protection is a bit that the
dev platform cannot represent will keep breaking.
A message with several links rendered the first preview correctly and the
rest as empty grey boxes. Three separate causes, all of which assumed one
link per message.

MetadataHelper.fetchMetadata always resolved `message.url`, which is the
first URL in the whole message text, so every preview in the message
fetched the same page. Its cache was keyed by message GUID alone, so all
the previews in one message shared a single entry and raced for it. And
LegacyUrlPreview derived its site label from `Uri.tryParse(message.text)`,
which is not a parseable URI once the text holds more than one link, so
the host came back null and the card had nothing to show.

fetchMetadata now takes the URL it should resolve and keys the cache on
message and URL together. LegacyUrlPreview takes the URL it is previewing
and uses it for both the label and the tap target. InteractiveHolder
already knew which MessagePart it was drawing, so it passes part.url.

message.metadata stays reserved for the message's primary URL. It is a
single blob and cannot describe more than one link, so letting every
preview write it meant whichever link resolved last overwrote the others
and all of them then rendered from that one on reload.

The tap target is also parsed with tryParse instead of Uri.parse, since
the previous version would throw on a message whose text is not a URI.

Verified: 394 Dart tests pass, analyzer clean on all three files. Not yet
confirmed against a real multi-link message on the device.
A link straight to a .jpg has no HTML metadata to scrape, so the image
fallback is the only thing that can give it a preview. It could never
fire. The condition tested data.url, which is not assigned until twenty
lines further down, so it was still null at that point and the whole
check short-circuited. Bare image links therefore rendered as empty
cards.

The pattern now matches the URL that was actually requested. Two smaller
faults went with it. It was anchored with $ against the full URL, so any
query string defeated it, and photo.jpg?size=large did not count as an
image; it now matches against the path. And the dots were unescaped, so
`.jpg` matched any character before "jpg" and something ending "Xjpg"
counted as an image.

webp, heic and bmp are added to the list, and the match is now
case-insensitive, since a URL ending .JPG is not unusual.

Verified: 394 Dart tests pass, analyzer clean.
Xare123 added 30 commits August 22, 2026 04:51
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