Skip to content

sync: ambient directories, receiving rules, and the gates that were red - #625

Open
alichherawalla wants to merge 339 commits into
mainfrom
release/sync-cross-platform
Open

sync: ambient directories, receiving rules, and the gates that were red#625
alichherawalla wants to merge 339 commits into
mainfrom
release/sync-cross-platform

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Your phone and your Mac become one device you can trust: files, chats, clipboard and models move between them by themselves, over your own network, and nothing leaves either device that you did not agree to.

293 commits, 335 files, +39,567 / -10,225.

What this gives you

Your devices find each other and stay paired. Discovery over the LAN with a persistent device name, a code you confirm on the other screen, and pairings that survive an app restart, a reinstall and an OS upgrade. Android stops advertising a LAN route it cannot actually dial, so a row never says reachable when it is not.

Files arrive on their own, but only the ones you chose. Screenshots and downloads share ambiently per source and per destination, with "auto", "ask me" and "off" obeyed exactly. Media access is requested at the moment you turn screenshot sharing on, not at launch. A synced files library holds what arrived, attributed to the device that sent it, and tells "we have this" apart from "we know about this" so Open and Share are never offered on a file that is gone.

The clipboard follows you, opt-in. Copy on one device, paste on the other, with the origin device preserved so you can see where a snippet came from. Bridged natively on Android, with guided access on iOS.

Chats and projects converge. A message that arrives from another device shows up when it arrives, not when something else happens to reload. Received messages keep the tools they were offered. Project knowledge bases accept pasted text directly.

Models transfer between devices. A model you already downloaded on one device can be sent to the other and is admitted as a real installed model, checksum-verified, rather than re-downloaded over cellular.

You decide what lands. Per-device receiving rules, a clipboard gate, and rules that are cleared on unpair so an id reused by a future device never inherits a decision you made about a different one.

Licensing and the device cap. Entitlement bootstraps during pairing, revalidates on launch, normalises a pasted key, and replaces the least-recently-used seat when you hit the cap instead of refusing.

Verification

  • 617 suites, 8,569 tests passing (8 skipped), full jest --coverage --forceExit --runInBand.
  • Android unit tests (:app:testDebugUnitTest) and iOS tests run in CI.
  • Two real phones, driven over adb and WebDriverAgent: __tests__/device/meshPairing.e2e.mjs pairs an iPhone and an Android device on the real network and asserts each one shows the other, and that neither claims a relationship the other denies.
  • Coverage floors: src at 80 on every metric, ./pro at 80 on statements/functions/lines and 79 on branches, which is where pro genuinely measures (79.44% of ~4,700 branches). Reaching 80 on branches needs about 78 more covered branches in ttsService, mcp/oauth metadata and knowledgeDocumentSyncService; that is real work, not a rounding nudge, so the floor is pinned just under the measured value rather than at a number nothing satisfies.

CI, and why it was red

Four separate causes, none of them a failing test:

  1. Every PR in all five repos was auto-closed when the branch rename deleted the old head refs. The red checks were dead runs from before the rename. This PR replaces sync: ambient directories, receiving rules, and the gates that were red #624.
  2. Android Lint was 68 of the job's 90 minutes. ESLint itself takes 36 seconds; npm run lint chained ./gradlew :app:lintDebug, which cold-configures every React Native native module on a macOS runner. CI now runs npx eslint .; Android Lint is a local pre-merge gate, the same call this workflow already documents for the Android build. Android unit tests still run here. Expect roughly 22 minutes instead of 90.
  3. Coverage thresholds failing by fractions of a point on a run where all 8,557 tests passed. See the floors above.
  4. A cross-suite timer leak failed exactly one rendered suite per run, under a different name each time, and passed in isolation every time. A 50ms token-buffer flush outlived its suite and fired inside the next one after jest.resetModules(). The harness now stops in-flight generation on teardown; the whole integration and rntl set (2,236 tests) then passes repeatedly with zero failures.

One ci job reports for this repo, matching the other three.

Tests worth calling out

The doctrine here is integration over mocks, with fakes only at genuine device boundaries. Every mock in the sync test surface of this release is a real boundary: native TCP, native mDNS, the filesystem, the keychain, the document picker. There are no mocks of our own code in the new sync tests.

Where older suites did mock our own code, they were deleted rather than repaired, and the journeys they claimed were rewritten against the real thing:

  • generationFlow.test.ts fed onStream itself, so the test was the model. 12 of its 15 cases were already covered by rendered suites; the two that were not are now real, asserted at the native engine.
  • imageGenerationFlow.test.ts was 60 tests over a stubbed image generator, six of them named after line numbers. What it never covered is the window a user actually sits in: STOP reaching the native generator, progress moving on the card, and a second send not starting a second diffusion.
  • ragFlow.test.ts mocked the DATABASE by matching SQL strings. Retrieval "found" whatever the matcher returned. Prompt-budget truncation and project scoping are now asserted over a real in-memory SQLite, including that a search never returns another project's documents.

Three sync modules that had no test at all are now covered: mesh residency policy (a refused foreground service must not fail sync start), availableSyncIds, and forgetDeviceRules.

Known gaps, recorded not hidden

docs/GAPS_BACKLOG.md carries the open items, including: ejecting a model mid-reply unloads the engine without stopping the generation (measured: native unloadModel 1, native stopGeneration 0); the ChatScreen journeys left uncovered by deleting a 155-case mockist suite, with the measured 8-point drop and the four named journeys; and the image-generation journeys not yet rewritten.

Greptile Summary

This release substantially expands cross-device synchronization, pairing, receiving controls, licensing, model transfer, clipboard sharing, and chat convergence while consolidating CI verification.

  • Adds native Android and iOS synchronization bridges for discovery, directories, screenshots, clipboard, and encrypted blob transfer.
  • Adds ambient and explicit file sharing, receiving preferences, transfer history, shared-file materialization, and model-package admission.
  • Adds persistent pairing identity, entitlement lifecycle handling, device-cap replacement, and device-specific rule cleanup.
  • Reworks chat, project knowledge, RAG, and model state flows with extensive integration, native-boundary, and device tests.
  • Consolidates lint, type-checking, architecture checks, Jest, Android tests, and iOS tests into one CI job.

Confidence Score: 5/5

The PR appears safe to merge because no eligible blocking failure or outstanding prior finding is established.

No blocking failure remains.

Important Files Changed

Filename Overview
src/services/sync/nativeSync.ts Adds the central native synchronization boundary and orchestration used by the new device-sync capabilities.
src/services/sync/mutation.ts Adds synchronization mutation handling for applying and propagating cross-device state changes.
src/services/sync/nativeProximity.ts Adds the React Native proximity and pairing bridge used for device discovery and trusted-peer communication.
android/app/src/main/java/ai/offgridmobile/sync/BlobServer.kt Implements Android-side encrypted blob reception and transfer lifecycle handling.
ios/BlobChannelServer.swift Implements the corresponding iOS blob-transfer server and payload reception path.
src/services/proLicenseService.ts Reworks entitlement validation and device-cap licensing behavior used during startup and pairing.
src/stores/chatStore.ts Extends chat state and persistence behavior to support convergent remote messages and synchronized context.
.github/workflows/ci.yml Consolidates repository gates into one macOS job and provisions the private Pro and shared workspace dependencies.

Sequence Diagram

sequenceDiagram
    participant A as Sending device
    participant D as Discovery and pairing
    participant R as Receiving rules
    participant T as Encrypted transfer
    participant B as Receiving device

    A->>D: Advertise stable identity
    B->>D: Discover and confirm pairing code
    D-->>A: Persist trusted peer
    D-->>B: Persist trusted peer
    A->>R: Announce clipboard, file, chat, or model
    R->>R: Apply peer and content-specific policy
    alt Receiving allowed
        R->>T: Authorize transfer
        T->>B: Send encrypted payload
        B->>B: Verify checksum and materialize
        B-->>A: Record completion
    else Ask or off
        R-->>B: Prompt or suppress transfer
    end
Loading

Reviews (3): Last reviewed commit: "fix(sync): make a failed receive discard..." | Re-trigger Greptile

Keep public Core limited to provider-safe device and fingerprint boundaries while private Pro owns raw entitlement credential storage and orchestration.
…ilable placement

Sync is a Pro feature, so a journey that exercises it runs on a licensed install. The
persistence journey installed a licensed phone in the registry but never set the app's
Pro flag, so the phone correctly resolved as unlicensed and never advertised - every
assertion after that failed for the wrong reason.

The state-sync journey asserted that pairing moves a device OUT of Available, which is
the rule that changed: a reachable device stays there with its saved row, and the Saved
section disappears when it has nothing left rather than captioning blank space.
…d the pro pointer

desktop-cdp.mjs makes the packaged Mac app DRIVABLE. It could not be driven the two
obvious ways: macOS refuses synthetic clicks to an ssh session (-25211), and the app
publishes no accessibility tree at all - every query returns -1700, so desktop-ssh's
labels() cannot read it either. Relaunching with --remote-debugging-port makes the
renderer scriptable, and a DOM click is not a synthetic OS event so nothing refuses
it. That keeps the REAL profile, licence and device identity, which a Playwright run
cannot: it launches its own instance on a throwaway profile and would join the mesh
as a different device.

connect-android-to-mac.mjs targets the Mac by FINGERPRINT, not name or row order,
because the Windows guest advertises itself as macos too - so "the macOS row" is
ambiguous on this LAN and a name match can pair the wrong machine.

Also bumps the pro submodule pointer, which was left behind: mobile-pro was pushed
but the parent still referenced the previous commit, so a fresh clone would have got
the app without the pro changes it now depends on.
The journey asserted that a forget left behind a row carrying retry/dismiss. That row
is gone by design: a device you have just removed reappearing beside ones you can still
connect to read as though the removal had failed. The revocation is still tracked and
retried in the background; the assertion now checks the device is absent.

Bumps the pro submodule.
The Pro screen already rendered "Device Not Active" while Sync, the upsell
suppression and the aha prompts all read the credential alone - so a deactivated
device kept working. They now share one selector, which delegates the rule to
hasProAccess in @offgrid/sync so mobile and desktop cannot drift on what "still
paid for" means.
A device removed from the licence was showing "Device Not Active" above the full
"INCLUDED WITH PRO" list with every paid feature working. There is no in-between:
you have Pro or you do not, and a device the roster dropped sees exactly what
someone who never bought it sees.

- ProDetailScreen branches on access, so eviction reverts it to the buy screen
- useIsProActive requires access as well as a registered screen, because
  registries cannot be unregistered and a device deactivated mid-session would
  otherwise keep every Pro entry point for the rest of the session
- loadProFeatures refuses to activate the bundle without access
- admission is persisted, so an eviction survives a relaunch instead of being
  granted again at every cold start
- one admission field instead of a boolean shadowing it, which after persistence
  would have contradicted itself outright
Two driver families, not four: iOS and Android are React Native (WDA, adb) and
macOS and Windows are Electron (CDP). connectSurface({kind}) hands back the same
verbs - openDevices, text, pairingCode, sees, rescan, startPairing,
enterPairingCode, isConnectedTo - so a flow is written once and runs anywhere.

Written because every new bit of sync behaviour was costing a fresh throwaway
script, and each one re-learned the same lessons the hard way.
pair({host, joiner}) is the foundational block: it reads each device's name off
its own screen, skips a link that is already up, shows the code on the host and
types it on the joiner. pair-devices runs one route, mesh-routes runs them all
and reports each independently, because a sweep that stops at the first failure
hides the other answers.

Verified live today: ios -> windows, android -> macos, macos -> windows.
… name

controlFor scanned labels forward from the device name. Android renders name-then-actions but iOS
renders the actions first, so a saved Mac on the iPhone reported 'lists no pair/repair control' when
it was one sync-reconnect tap away. Take the nearest match in both directions, as nameOf already did.

mesh-routes carried its own endpoints and defaulted Windows to :9223 while the tunnel had moved to
:9224, so a healthy app read as a dead one. Endpoints now live in mesh-config, overridable by env or
flag. nameOf moves into the surface layer as deviceName, one implementation per driver family, so a
flow never has to know how a platform spells its own name.
Same gap as the desktop: the engine's disconnect notification never reached the orchestrator, so a
saved peer that lost its session was never retried.
… the list

run-flows.mjs owns ordering and reporting, flows/ own the journey, sync-surface owns the platform. A
flow says "forget, then pair, then assert" and cannot tell whether it is driving WDA, adb or CDP;
adding a platform touches only the surface, adding a flow only flows/. Selectors move to
selectors.mjs so no flow contains a string that has to match the product.

The surface now knows about confirmation sheets, which it has to. Every RN read works off the flat
accessibility label list, and an open sheet REPLACES that list - so "is this device still connected?"
answered false while the device was perfectly connected. Forget tapped a guessed testID and swallowed
the failure, leaving the real "Evict device" sheet untouched; the check that followed read the
covering sheet, concluded the credential was gone, and the route would have passed without one being
dropped. openSheet() makes the sheet visible to the surface, confirmDestructive() requires the
confirmation rather than hoping for it, and the runner dismisses any sheet a failed route leaves open
so the next flow does not start by reading the wrong screen.
…f the network

The promise this covers is the one a person feels rather than drives: you walk out of range, you come
back, and the devices are talking again without you opening anything. So the assertion window
contains no taps at all - a flow that presses Reconnect passes on a build where nothing heals itself,
which is exactly the state this codebase was in.

goOffline(ms) is one self-restoring verb rather than an off switch and an on switch, because the
desktops are driven THROUGH the network they are being asked to leave: turning it off over ssh would
cut the channel that would later turn it back on, and strand the box until somebody walked to it. The
machine schedules its own return before it goes. Android is the same shape for consistency, iOS
throws with the reason rather than skipping quietly.

Verified live: the Mac saw the Android drop, and with nothing touched, reconnected in 5s.
isConnectedTo sliced labels forward from the device name, so on an iPhone - which renders the status
line before the name on some rows and nine labels after it on others - a connected Mac reported as
not connected. The same directional bug controlFor had. Take the NEAREST status line in either
direction and read what it says, rather than asking whether a Connected line exists nearby: rows sit
next to each other, so a window wide enough to find this row's status can also find the next row's.

The window is 14 because a connected row carries three more controls than a disconnected one, which
pushes its status past a window sized on the disconnected case - the exact reading that made a live
mesh look half down.
Two journeys waited for the word "Discoverable" to know the Sync screen had arrived. Neither is
about discoverability, and the card no longer prints that word when the device is simply
discoverable - the switch beneath it says so. Wait for the device card and the pairing code instead,
which are on the screen whatever the mesh is doing.
"Peers stop seeing it" is only observable against a device that is NOT already paired - a paired peer
keeps its link whether or not the advertisement is running, because hiding is about being found, not
about being reachable. The card says so itself: "Discoverable to new devices". With every pair in the
mesh already paired, the testable half is the guarantee underneath, and it is the one worth guarding:
a reader could plausibly implement hiding by tearing down the transport, and every existing link
would go with it.

isDiscoverable reads the card's STATUS LINE rather than the switch. The switch's value is not in the
label tree on both phones - iOS emits a "1"/"0" beside the testID and Android emits nothing - so a
reader built on it works on one phone and throws on the other. The status line is a contract: the
card prints it only when it says something the switch does not, so a discoverable device shows none
and a hidden one names itself.

The flow restores the setting it found, in a finally. Discoverability persists across restarts, so a
flow that fails half way through and leaves a device hidden has changed a user's setting - and the
next flow would fail to discover anything for a reason nowhere in its own output.

Verified live: Android hidden 45s, the Mac kept the link throughout, setting restored.
The gentle counterpart to forget, and the one flows should reach for. Forget frees a licence seat and
ends trust everywhere - it cost a real pairing and a seat when a flow used it as a routine teardown.
Disconnect only closes the link: the pairing survives, the device comes back with a tap rather than a
code, and nothing about the licence moves.
… its projector

One 508 MB download had 35 rows in the picker. The store identified a model by its ABSOLUTE path, and on
iOS that path lives under a container UUID that changes on every reinstall - so after each install the
scan found no row matching the file's new path, called it untracked, adopted it again under a fresh
`recovered_<name>_<Date.now()>` id, and left the old row behind. 35 rows, 29 app starts, four days.
Timestamps in the id made it worse: the same file adopted twice produced two ids nothing could reconcile.

A file name is unique within the models dir, which is why it is the stable key - the conclusion
useTextModels.ts had already reached for its own display matching, applied here where rows are created.
The scan now REPAIRS a known row's path instead of adopting a second one, ids are derived from the file
name alone, and load collapses existing duplicates to one row per file, preferring a real catalog id
over a recovered one and then the earliest download. Rows are dropped, never files.

Adoption also links the projector now, via the strict name+variant rule the loader already uses. It was
skipping mmproj files entirely, so a recovered vision model came back as text - the primary alone,
508 MB where the package is 706 MB.

Note for whoever gets there next: scanForUntrackedImageModels has the same path-identity flaw and the
same `recovered_<name>_<Date.now()>` id, untouched here.
`passive` on the phone surfaces: attach to whatever is on screen, launch nothing. A WDA session created
WITH a bundle id ACTIVATES that app, which terminates what it was doing - and the snapshot tool, which
described itself as "read-only by design", called exactly that on every step. It killed a 706 MB model
transfer mid-receive. Android's session() relaunches to the foreground for the same reason and is now
skipped too.

The honest limit is recorded in GAPS_BACKLOG: even a bundle-less WDA session appears to deactivate the
foreground app, so `passive` will not relaunch but cannot be trusted not to disturb. While a person is
driving a device, ask them for a screenshot instead - it is the only genuinely free observation on iOS.

rules.md points at the canonical SSOT debugging rule in shared/.
scan.ts crossed the 500-line cap adding the projector link, and the cap was right: one file was doing
crash recovery, projector linking, image-zip reconciliation AND importing a file the user picked. Local
import is the odd one out - it brings in a file from OUTSIDE the models dir, where everything else adopts
what is already inside - so it moves to importLocalModel.ts with its own options type.

parseSizeInt is exported rather than copied, because both files need it and a second copy of a parser is
a second source of truth for how a size is read.
The image registry answered "is this model still on disk" with its own copy of
the text registry's logic, and the copy had drifted: it used a raw RNFS.exists
where the text loader uses the verifiable probe. That is the G1 data-loss guard
protecting exactly half the registry.

Consequences on the image path:
- a transient FS reject rejected the whole Promise.all, threw the load, and
  handed the caller an EMPTY registry
- the scan then re-adopted every directory, so catalog rows were replaced by
  `recovered_` ones and anything holding the previous id dangled
- an unverifiable probe pruned the row and PERSISTED the prune, silently
  unregistering a valid multi-GB model

Extracted to reconcileStoredPaths.ts, now the single home of basenameOf,
resolveStoredPath, probeExists and reconcilePrimaryPaths. Both loaders call it;
neither owns a copy. collapseDuplicateFileRows generalised to collapseRows with
file- and directory-keyed wrappers, so the image registry gets the same repair
for rows already grown under the old ids.

Image scan identity is now the directory NAME, not the absolute path, and the
adopted id is `recovered_<name>` with no Date.now() — the same conclusion the
text scan reached, applied where image rows are created.

Typecheck clean, lint clean, 350 modelManager tests green. NOT device-verified.
nativeSync takes one resolver and feeds BOTH halves from it - the engine answering
inbound requests and the orchestrator initiating outbound ones - so this device
cannot offer a codeless join it would itself refuse.

Bumps pro for the host adapter.
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

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