Skip to content

Store booleans as JSON booleans in the Realtime Database on Apple targets - #840

Draft
kihaki wants to merge 3 commits into
GitLiveApp:masterfrom
kihaki:fix/apple-boolean-nsnumber-encoding
Draft

Store booleans as JSON booleans in the Realtime Database on Apple targets#840
kihaki wants to merge 3 commits into
GitLiveApp:masterfrom
kihaki:fix/apple-boolean-nsnumber-encoding

Conversation

@kihaki

@kihaki kihaki commented Jul 30, 2026

Copy link
Copy Markdown

Problem

Booleans written to the Realtime Database from Apple targets are stored as 1/0 instead of true/false (#667, previously #275). Android and JVM clients that map such nodes into Boolean fields then crash with Failed to convert value of type java.lang.Long to boolean. #275 was closed by #549, but that PR only fixed decoding (reading 1/0 back as a Boolean), the written data is still numeric.

Root cause

The Firebase Apple SDKs only serialize an NSNumber as a JSON boolean when it is one of the CFBoolean singletons (kCFBooleanTrue/kCFBooleanFalse, the instances behind NSNumber(bool:)). A Kotlin Boolean crossing the interop boundary is boxed by Kotlin/Native as its own NSNumber subclass, which is not a CFBoolean, so it serializes by numeric value. JetBrains considers this intended behavior (JetBrains/kotlin-native#3857).

Fixing this inside the encoder is impossible: Kotlin/Native bridges in both directions, so a CFBoolean can never be held in Kotlin code. Verified on Kotlin 2.2:

val n: Any = NSNumber(bool = true)
n::class            // class kotlin.Boolean — bridged back immediately
n === true          // true
// same for interpretObjCPointer(kCFBooleanTrue!!.rawValue)

Fix

Writes. The one place a CFBoolean survives is inside a Foundation container that Kotlin never unwraps. NSJSONSerialization parsing produces exactly that: the result is a lazy Foundation-backed view (NSDictionaryAsKMap), and the CFBooleans inside it reach the SDK untouched. New appleMain helper withFoundationBooleans() in firebase-common-internal serializes a boolean-carrying container to JSON text (in Kotlin, where boolean identity is still known) and parses it back with NSJSONSerialization. firebase-database applies it at every value hand-off: setValue, updateChildValues, both onDisconnect variants, and transaction results. A boolean at the root (setValue(true)) cannot survive on its own, so it is wrapped in the SDK-supported {".value": x} leaf form (kPayloadValue in FSnapshotUtilities.m), which resolves to a plain scalar write.

Float values inside transformed containers are widened to Double before writing the text form, so they parse to the same Double the directly bridged NSNumber produced (1.2f stays 1.2000000476837158) and boolean presence never changes adjacent numeric data.

Queries. startAt/endAt/equalTo(Boolean) previously sent the boxed number too, which happened to match the numeric writes. With writes fixed, the bounds must be real CFBooleans as well or boolean queries would silently stop matching. A scalar cannot ride in a JSON container, so this uses a small cinterop shim (booleanQuery.def): the boolean enters ObjC as a primitive C BOOL (unaffected by NSNumber boxing), the shim selects kCFBooleanTrue/kCFBooleanFalse and invokes the query selector via performSelector, and the CFBoolean never surfaces in Kotlin. The shim is typed id so it needs no FirebaseDatabase headers.

Behavior is unchanged for anything the round-trip cannot represent: boolean-free containers are returned identically (fast path), and containers holding custom objects, non-String keys, or non-finite doubles fall back to the previous behavior.

Not covered

Firestore has the same underlying issue but a non-JSON value model (Timestamp, GeoPoint, DocumentReference), so this PR is scoped to the Realtime Database. The helper intentionally lives in firebase-common-internal so a Firestore fix can reuse the technique with its own traversal.

Tests

FoundationValueTest (firebase-common-internal appleTest, runs without pods) asserts via NSJSONSerialization output, the same Foundation serialization the SDK uses:

  • baseline: an untransformed mapOf("v" to true) serializes as {"v":1} (the bug)
  • transformed maps/lists/nested containers serialize with true/false
  • 1.2f keeps its bridged widening 1.2000000476837158
  • numbers (including 2^53+1), strings with escapes, and ServerValue sentinels are preserved
  • fast-path identity for boolean-free containers, fallback identity for unrepresentable content

FirebaseDatabaseTest (commonTest, against the emulator) gains raw-snapshot assertions that distinguish stored booleans from stored 0/1 (the typed decoder deliberately accepts both, so the existing typed tests could not catch this):

  • direct set, nested set through a serializable class, updateChildren, transaction
  • onDisconnect().setValue and onDisconnect().updateChildren
  • orderByValue().equalTo/startAt/endAt(Boolean) against a node that also contains the numbers 0 and 1, so a numeric bound would return the wrong children

Verified locally: firebase-common-internal iosSimulatorArm64 + macosArm64 test suites, firebase-database iosSimulatorArm64 emulator suite (19/19) and jvm emulator suite (19/19), kotlinter clean.

Fixes #667. Relates to #275 / #549.

kihaki added 2 commits July 30, 2026 09:06
…gets

The Firebase Apple SDKs only serialize an NSNumber as a JSON boolean if it
is a CFBoolean singleton. A Kotlin Boolean crossing the interop boundary is
boxed as a different NSNumber subclass, and Kotlin/Native bridges any
CFBoolean entering Kotlin back to a Kotlin Boolean, so a CFBoolean can never
be held in Kotlin code. Booleans written from Apple targets therefore end up
as 1/0 in the database (GitLiveApp#275, GitLiveApp#667), which also crashes Android readers that
map them into Boolean fields.

Fix: before handing an encoded value to the FirebaseDatabase SDK, rebuild
boolean-carrying containers as Foundation objects by serializing them to
JSON text (where boolean identity is still known) and parsing with
NSJSONSerialization. The parsed result is a lazy Foundation-backed view, so
the CFBooleans inside never surface to Kotlin and reach the SDK intact. A
boolean at the root is wrapped in the SDK-supported {".value": x} leaf form.
Containers without booleans and values JSON cannot represent are passed
through unchanged.
Boolean startAt/endAt/equalTo previously sent the Kotlin Boolean boxed as a
numeric NSNumber, which happened to match the numeric writes. With writes
fixed they must send CFBooleans too, or boolean queries silently stop
matching. A scalar cannot ride in a JSON container, so a small cinterop shim
receives the bound as a primitive C BOOL and applies the query selector on
the ObjC side, where the CFBoolean never surfaces in Kotlin.

Floats inside transformed containers are widened to Double before writing
the text form, so they parse to the same Double the directly bridged
NSNumber produced and boolean presence never changes adjacent numbers.

New emulator tests assert raw snapshot values, which distinguish stored
booleans from stored 0/1 where the typed decoder deliberately does not:
direct set, nested set, updateChildren, transactions, both onDisconnect
variants, and boolean query bounds against neighboring numeric 0/1 nodes.
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.

iOS RTDB inconsistencies

2 participants