Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,89 @@ This file holds unreleased changes and the current release. Older releases are
archived by series under [docs/changelog/](docs/changelog/); see the
[archive index](docs/changelog/README.md).

## [Unreleased]

### Fixed

- **Eight React Native methods were unreachable on iOS, and the bridge now
proves it cannot happen again.** `RCT_EXTERN_METHOD` does not declare a Swift
method, it records a selector that React Native resolves against the class at
module load; one it cannot find is dropped with a log line and the JS method
is simply absent. Neither compiler sees both halves, and
`OfflineProtocolModule.swift` is the one bridge source no CI job compiles, so
three separate drifts shipped. `wipePersistedState` kept the pre-rename
`userId:` label and had been uncallable since 0.21.0, which meant logging out
could not erase the account it had just signed out of and every prior
account's MLS identity and sealed state stayed on disk. `setBatteryState`,
`getIsCharging`, `updateRelayConfig` and `getRelayConfig` were written in
Swift, Kotlin and TypeScript and never declared in the bridge at all, so
since 0.22.0 every relay setting an application passed to `create()` was
discarded on iOS behind a `console.warn`: **applications that configure
`allowRelay`, `minBatteryForRelay` or `relayPriority` will see those settings
take effect on iOS for the first time on this release.** `dataListSpaces`,
`dataFlushAll` and `dataWipeAll` took a labelled first parameter, which Swift
exports as `dataListSpacesWithResolver:` rather than `dataListSpaces:`, and
stopped resolving in 0.23.0. Android was never affected: its dispatch is by
method name and position, and the Kotlin side was correct throughout.
`react_native_ios_objc_shim_and_swift_agree_on_every_selector` now reads both
bridge halves and the TypeScript, and fails on any selector one side has and
another does not.

- **Seven more iOS methods resolved but ran on the wrong argument bits.** The
bridge declares each parameter's type as text, and React Native picks the
`RCTConvert` converter from that text and the calling convention from the
Swift parameter's runtime encoding, then calls the one through a function
pointer cast to the other. `nonnull NSNumber *` against a Swift `Int`
therefore hands the method an object pointer read as a 64-bit integer, which
is the pointer bits of a tagged `NSNumber` and never the number. The type
table in `BRIDGE_MAINTENANCE.md` had recommended exactly that pairing since
v0.3.3, the release that also introduced the first of these methods, so
`sendMessage`, `sendMessageRich` and `sendPresenceUpdate` silently pinned
every priority and status to their
`default:` arm, `setBatteryLevel` and `setBatteryState` recorded a clamp bound
rather than the level, and `processFileChunk` and `blePeerDiscovered` reached
a narrowing conversion that traps, aborting the application. Nothing was
logged in any of the seven cases. The type table is corrected, the two
conversions that now receive real values reject or clamp out-of-range input
instead of trapping, and the selector guard gained a third direction that
compares the ABI class of every parameter behind a shared selector.

- **Fifteen iOS conversions aborted the app instead of rejecting the call.**
A narrowing conversion like `UInt8(_:)` traps on out-of-range input rather
than returning a value the bridge could reject, and every number reaching
these conversions came straight from JavaScript. Twelve of them turned a
`[NSNumber]` argument into bytes, so any array element outside 0...255
crashed the application: reachable from a malformed BLE fragment, a Wi-Fi
Direct or internet frame, an MLS ciphertext or Welcome, a key package, or a
file chunk. The thirteenth was the `initialTtl` config field, which made
`create()` abort on iOS for an application passing a value above 255, where
Android truncated the same value and started normally. The last two narrowed
the DORS `historyWindowSize` to `Int` before clamping it, which is too late
to help: a negative number from JavaScript arrives at `uint64Value` as
`UInt64.max`, so the conversion traps before the surrounding clamp can run,
and both `create()` and `updateDorsConfig` aborted on a negative value. Byte
arrays now convert through a helper that throws into the rejection each call
site already had, `initialTtl` and `historyWindowSize` are clamped in the
domain they arrive in, and
`react_native_ios_bridge_bounds_every_byte_it_builds_from_javascript` fails
on any byte conversion in the bridge that does not carry its own bound.

Unlike the ABI mismatches above, these were never masked by anything. Array
arguments cross as `NSArray *` against `[NSNumber]`, which has agreed since
the UniFFI migration, so every one of these has been reachable in every
release that shipped the method, and the transport ones are reachable by a
remote peer rather than only by the application's own code.

- **`forwardMessage` hangs on iOS in debug builds** rather than forwarding.
React Native forces every `NSNumber` argument to non-null, because numbers
are not nullable on Android, and refuses a null one before the Swift method
is entered, so neither the resolver nor the rejecter ever runs. The
TypeScript passes `null` whenever a caller omits the priority. No declaration
in the bridge can fix this; it needs a contract change across TypeScript,
Swift and Kotlin, and is tracked in
[#417](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/417).
Release builds are unaffected, as the check is compiled out.

## [0.24.0] — 2026-08-24

> **A door lock speaks this protocol now, and not a smaller version of it.**
Expand Down
102 changes: 100 additions & 2 deletions bindings/react-native/ios/BRIDGE_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Add or update the corresponding `RCT_EXTERN_METHOD` in `OfflineProtocolModule.m`
```objective-c
RCT_EXTERN_METHOD(sendMessage:(NSString *)recipient
content:(NSString *)content
priority:(nonnull NSNumber *)priority
priority:(NSInteger)priority
replyToMsg:(NSString *)replyToMsg
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
Expand All @@ -56,13 +56,88 @@ Map Swift types to Objective-C types:
|------------|------------------|
| `String` | `NSString *` |
| `String?` | `NSString *` (nullable) |
| `Int` | `nonnull NSNumber *` |
| `Int` | `NSInteger` |
| `Double` | `double` |
| `Bool` | `BOOL` |
| `NSNumber` | `nonnull NSNumber *` |
| `[NSNumber]` | `NSArray *` |
| `NSDictionary?` | `NSDictionary *` (nullable) |

**A primitive and an object are not interchangeable here, and mixing them is
silent.** React Native picks the `RCTConvert` converter from the type text you
write above and the calling convention from the Swift parameter's runtime
encoding, then calls the first through a function pointer cast to the second.
Write `nonnull NSNumber *` against a Swift `Int` and `+[RCTConvert NSNumber:]`
returns an object pointer that is then read as a 64-bit integer, so the method
runs with the pointer bits of a tagged `NSNumber` where the number should be.
Write it against a Swift `Double` and an integer register is read as a floating
point one. Nothing is logged either way. This row read `Int` to
`nonnull NSNumber *` from v0.3.3 until this release, and seven methods
followed it.

Take an `NSNumber` on the Swift side only where the argument is genuinely
optional, and know that React Native does not really support that: it forces
every `NSNumber` argument to non-null whatever you declare, because numbers are
not nullable on Android. A null one is then refused before the Swift method is
entered, so neither the resolver nor the rejecter runs and the promise never
settles. `forwardMessage` is the one method in this bridge that relies on a
nullable number, and it hangs on iOS debug builds for that reason; there is no
spelling of the declaration that fixes it, so it needs a contract change across
all three languages. That is tracked in
[#417](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/417).
Until it lands, do not add a second nullable-number argument.

**Note**: All `@objc` methods must include `resolver` and `rejecter` parameters (React Native Promise pattern).

### Step 4: Leave the first parameter unlabelled

Write `_ recipient: String`, not `recipient: String`. Swift exports a labelled
first parameter with a `With` infix, so `dataListSpaces(resolver:rejecter:)`
becomes the selector `dataListSpacesWithResolver:rejecter:` and no longer
matches `RCT_EXTERN_METHOD(dataListSpaces:...)`. Three data-layer methods
drifted into that shape in 0.23.0 and stopped resolving.

Repair it by dropping the label in Swift. Do not write the `With` form in the
bridge instead: React Native derives the JS method name from the selector text
before its first colon, so that spelling renames the JS method rather than
fixing it.

### Step 5: Bound every number you narrow

`UInt8(someInt)` traps. It does not return nil, throw, or truncate: it aborts
the process, and every number reaching this file came from JavaScript, so an
out-of-range value is a caller mistake that must reject the promise instead.

Convert byte arrays through the `jsBytes` helper, which throws an `NSError`
into the rejection your `do`/`catch` already has:

```swift
let bytes = try jsBytes(data, "data") // not data.map { UInt8($0.intValue) }
let optional = try maybe.map { try jsBytes($0, "keyPackage") }
```

For a scalar, bound it where you write it (`min`/`max`, `UInt8(exactly:)`,
`UInt8(clamping:)`) or `guard` the range before the conversion, as
`processFileChunk` does for its `UInt32` and `UInt64` arguments. Twelve array
conversions and the `initialTtl` config field were unbounded until this
release: a peer sending a malformed fragment, or an application passing `initialTtl: 300`
to `create()`, aborted the app on iOS where Android truncated.

**The clamp has to sit inside the conversion, not around it.** Wrapping a
narrowing conversion in `min`/`max` reads as bounded and is not: the conversion
runs first, so it traps before any of the clamp applies. This reaches unsigned
values too, because a negative JavaScript number arrives at `uint64Value` as
`UInt64.max`, and narrowing that to `Int` aborts. Clamp in the domain the value
arrives in, or convert with `Int(clamping:)`. Two DORS config paths carried the
wrong order until this release, so a `historyWindowSize` of `-1` passed to
`create()` aborted the app on iOS.

`react_native_ios_bridge_bounds_every_byte_it_builds_from_javascript` in
`offline-protocol-uniffi` fails on any `UInt8(...)` in this file whose argument
does not carry its own bound. It reads bytes only: a scalar narrowing like the
one above is held by this checklist and by review, because the text of
`Int(raw)` cannot say whether `raw` is already bounded.

## Common Issues

### Missing Parameter
Expand All @@ -81,6 +156,26 @@ Map Swift types to Objective-C types:

**Fix**: Check the type mapping table above

### Renamed Parameter Label

**Error (boot log)**: ``The Objective-C `...` method signature for the JS method
`...` can not be found in the Objective-C definition of the
OfflineProtocolModule module.``

**Cause**: The selector here and the selector Swift exports differ. Renaming a
parameter in Swift renames the selector, so a bridge left on the old label
declares a method that no longer exists.

**Fix**: Rename the label here too. This is not caught by any compiler; it is
caught by `react_native_ios_objc_shim_and_swift_agree_on_every_selector` in
`offline-protocol-uniffi`, which compares the selector sets of both files and
also fails when the TypeScript calls a method this bridge never exports. Run
it with:

```bash
cargo test -p offline-protocol-uniffi --lib react_native_ios_objc_shim
```

## Threading contract for the transport managers

`BleManager` (and the same reasoning applies to the other transport managers)
Expand Down Expand Up @@ -153,6 +248,9 @@ Before committing changes:
- [ ] All `@objc func` methods in Swift have corresponding `RCT_EXTERN_METHOD` declarations
- [ ] Parameter names and types match between Swift and Objective-C
- [ ] All methods include `resolver` and `rejecter` parameters
- [ ] The first Swift parameter is unlabelled (`_`)
- [ ] Every narrowing conversion is bounded, and byte arrays go through `jsBytes`
- [ ] `cargo test -p offline-protocol-uniffi --lib react_native_ios` passes
- [ ] Build succeeds without warnings
- [ ] Test the method from JavaScript to ensure it works

Expand Down
33 changes: 24 additions & 9 deletions bindings/react-native/ios/OfflineProtocolModule.m
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(wipePersistedState:(NSString *)appId
userId:(NSString *)userId
profile:(NSString *)profile
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

Expand Down Expand Up @@ -47,14 +47,14 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)

RCT_EXTERN_METHOD(sendMessage:(NSString *)recipient
content:(NSString *)content
priority:(nonnull NSNumber *)priority
priority:(NSInteger)priority
replyToMsg:(NSString *)replyToMsg
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(sendMessageRich:(NSString *)recipient
content:(NSString *)content
priority:(nonnull NSNumber *)priority
priority:(NSInteger)priority
replyToMsg:(NSString *)replyToMsg
options:(NSDictionary *)options
resolver:(RCTPromiseResolveBlock)resolve
Expand Down Expand Up @@ -164,7 +164,7 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)

// BLE transport methods
RCT_EXTERN_METHOD(blePeerDiscovered:(NSString *)peerId
rssi:(nonnull NSNumber *)rssi
rssi:(NSInteger)rssi
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

Expand Down Expand Up @@ -194,13 +194,21 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)
rejecter:(RCTPromiseRejectBlock)reject)

// Battery management
RCT_EXTERN_METHOD(setBatteryLevel:(nonnull NSNumber *)level
RCT_EXTERN_METHOD(setBatteryLevel:(NSInteger)level
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(setBatteryState:(NSInteger)level
isCharging:(BOOL)isCharging
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(getIsCharging:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

// Relay management
RCT_EXTERN_METHOD(setRelayPriority:(NSString *)priorityString
resolver:(RCTPromiseResolveBlock)resolve
Expand All @@ -212,6 +220,13 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)
RCT_EXTERN_METHOD(isRelay:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(updateRelayConfig:(NSString *)configJson
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(getRelayConfig:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

// Transport metrics
RCT_EXTERN_METHOD(getTransportMetrics:(NSString *)transportType
resolver:(RCTPromiseResolveBlock)resolve
Expand Down Expand Up @@ -267,9 +282,9 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)

// File Transfer Operations
RCT_EXTERN_METHOD(processFileChunk:(NSString *)fileId
chunkIndex:(nonnull NSNumber *)chunkIndex
totalChunks:(nonnull NSNumber *)totalChunks
fileSize:(nonnull NSNumber *)fileSize
chunkIndex:(NSInteger)chunkIndex
totalChunks:(NSInteger)totalChunks
fileSize:(double)fileSize
fileName:(NSString *)fileName
fileChecksum:(NSString *)fileChecksum
data:(NSArray *)data
Expand Down Expand Up @@ -681,7 +696,7 @@ @interface RCT_EXTERN_MODULE(OfflineProtocolModule, RCTEventEmitter)

// Presence, Typing, Read Receipts
RCT_EXTERN_METHOD(sendPresenceUpdate:(NSString *)recipient
status:(nonnull NSNumber *)status
status:(NSInteger)status
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

Expand Down
Loading
Loading