diff --git a/README.md b/README.md index 6fb07e4..71aa208 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ See the `Package.swift` files in the | Module | iOS API Coverage | Highlights | Notable Gaps | |---|:---:|---|---| | `SkipFirebaseCore` | ~80% | `FirebaseApp.configure` (default + named + custom `FirebaseOptions`), app lifecycle, `Timestamp`, deep Kotlin↔Swift value conversion helpers | Custom `FirebaseLogger`, advanced data-collection toggles | -| `SkipFirebaseFirestore` | ~80% | Collections, documents, batched writes, queries, `Filter.and`/`.or` composition, `FieldPath`, aggregate queries (`count`/`average`/`sum`), snapshot listeners, `FieldValue` sentinels, `LoadBundleTaskProgress`, errors mapped to `NSError` with `FirestoreErrorDomain`/`FirestoreErrorCode` | `runTransaction` (`Transaction` is currently a passthrough wrapper), `Codable` document encoding/decoding, `GeoPoint`, `FirestoreSettings`/cache configuration, `InputStream`-based bundle loading | +| `SkipFirebaseFirestore` | ~90% | Collections, documents, batched writes, queries, `Filter.and`/`.or` composition, `FieldPath`, aggregate queries (`count`/`average`/`sum`), snapshot listeners, `FieldValue` sentinels, `LoadBundleTaskProgress`, `GeoPoint`, `FirestoreSettings` with persistent/memory cache configuration, `DocumentSnapshot.metadata`/`reference`/`get(FieldPath)`, completion-based `addDocument`, `getDocument(source:)`, `setData(mergeFields:)`, `Codable` encoding/decoding via `FirestoreEncoder`/`FirestoreDecoder`, errors mapped to `NSError` with `FirestoreErrorDomain`/`FirestoreErrorCode` | `runTransaction` (`Transaction` is currently a passthrough wrapper), `InputStream`-based bundle loading, `Codable` decoding uses `snapshot.decoded()` on Android (not `data(as:)` due to a Skip transpiler limitation with `T.Type` parameters) | | `SkipFirebaseAuth` | ~60% | Email/password sign-in, anonymous sign-in, email-link sign-in, interactive OAuth provider sign-in (Activity-based), state-change listener, ID-token retrieval, profile changes, account linking, reauthentication, `fetchSignInMethods`, `ActionCodeSettings`, partial `AuthErrorCode` mapping | Phone auth (`PhoneAuthProvider`, SMS verification), multi-factor (`MultiFactor`/`MultiFactorResolver`), `applyActionCode`/`checkActionCode`/`confirmPasswordReset`/`verifyPasswordResetCode`, custom-token sign-in, `GameCenterAuthProvider`, language/tenant configuration, `updateEmail`/`updatePassword` | | `SkipFirebaseStorage` | ~80% | Bucket and reference resolution, upload (`putFile`/`putData` in both callback and `async` forms), download (`getData`/`write(toFile:)`), `StorageMetadata` read/write, `downloadURL`, `delete`, pause/resume/cancel on uploads, `list`/`listAll` with pagination (`pageToken`), live `Progress` snapshot observers on uploads and file downloads | Full `StorageError` code mapping, `putStream`/`putString` | | `SkipFirebaseMessaging` | ~70% | FCM token retrieval + auto-refresh, topic subscribe/unsubscribe, `MessagingDelegate`, `MessagingService` integrating with iOS-style `UNUserNotificationCenterDelegate`, intent routing, localized `loc_key`/`loc_args` title/body, `RemoteMessage` → `UNNotification` translation | Per-sender FCM token APIs and notification-service-extension helpers (`populateNotificationContent`, `exportDeliveryMetricsToBigQuery`) are stubbed out as `@available(*, unavailable)` | @@ -41,7 +41,7 @@ See the `Package.swift` files in the ### What is working well -- **Firestore** is the most complete module: all common CRUD, queries (including `Filter` combinators and `FieldPath` predicates), snapshot listeners (with `MetadataChanges` support), batched writes, aggregate queries, and `FieldValue` sentinels round-trip correctly between Swift and the Firestore Android SDK. Errors are mapped to `NSError` with `FirestoreErrorDomain`/`FirestoreErrorCode` so error handling looks the same on both platforms. +- **Firestore** is the most complete module: all common CRUD, queries (including `Filter` combinators and `FieldPath` predicates), snapshot listeners (with `MetadataChanges` support), batched writes, aggregate queries, `FieldValue` sentinels, `GeoPoint`, and `FirestoreSettings` (persistent and memory cache) round-trip correctly between Swift and the Firestore Android SDK. `Codable` models can be encoded with `setData(from:)` and decoded with `snapshot.decoded()` on Android (note: use `snapshot.data(as:)` from `FirebaseFirestoreSwift` on iOS). Errors are mapped to `NSError` with `FirestoreErrorDomain`/`FirestoreErrorCode` so error handling looks the same on both platforms. - **Auth** covers the most common sign-in flows (email/password, anonymous, interactive OAuth via the system browser, email-link) and exposes a familiar `User` API with profile changes, account linking, reauthentication, ID-token retrieval, and a state-change listener. - **Messaging** handles the full FCM lifecycle: token retrieval and auto-refresh, topic subscribe/unsubscribe, `RemoteMessage` → `UNNotification` translation (including localized `loc_key`/`loc_args` substitution from the app's `Localizable.strings`), and a `MessagingService` that integrates with iOS-style `UNUserNotificationCenterDelegate`. - **Storage** supports both data-based and file-based uploads/downloads in callback and `async` forms, plus full `StorageMetadata` read/write. @@ -52,6 +52,7 @@ See the `Package.swift` files in the - **Realtime Database** (`SkipFirebaseDatabase`) is currently only a stub. `DatabaseReference`, `observe`/`observeSingleEvent`, queries, and writes have not been bridged. Apps that rely on the Realtime Database should use Firestore instead or contribute the missing wrappers. - **Phone-number authentication and multi-factor flows** in `SkipFirebaseAuth` are not bridged. Apps requiring SMS verification or MFA need to drop into `#if SKIP` blocks and call the Android Firebase SDK directly. - **`runTransaction`** in Firestore is not implemented — the `Transaction` class is currently a passthrough wrapper with no operations. Multi-document atomic reads-then-writes need to be expressed as `WriteBatch` commits or as Kotlin-side code today. +- **`Codable` decoding API differs by platform** — on Android use `snapshot.decoded()` with explicit type annotation (`let m: MyModel = try snapshot.decoded()`); on iOS use `FirebaseFirestoreSwift`'s `snapshot.data(as: MyModel.self)`. The `setData(from:)` encoding API works identically on both platforms. - **`SkipFirebaseFunctions`** does not support streaming RPCs (`stream`). The Firebase Android SDK has no SSE/streaming equivalent to the iOS `AsyncThrowingStream`-based `stream` API — Android Functions calls are always one-shot request/response. Any code that iterates over `.stream(...)` on iOS will not compile on Android. The `Callable` Codable convenience wrapper is also not bridged; use `call(_ data: Any?)` directly and decode the result manually on Android. - **`SkipFirebaseRemoteConfig`** does not yet expose `addOnConfigUpdateListener` (real-time config updates) or custom signals. - **`SkipFirebaseAppCheck`** ships only the `Debug` provider factory; custom provider implementations are not yet bridgeable. diff --git a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift index ee373c9..e842d1f 100644 --- a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift +++ b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift @@ -146,7 +146,7 @@ public final class FirebaseOptions { // https://firebase.google.com/docs/reference/swift/firebasefirestore/api/reference/Classes/Timestamp // https://firebase.google.com/docs/reference/android/com/google/firebase/Timestamp -public class Timestamp: Hashable, KotlinConverting { +public final class Timestamp: Hashable, Codable, KotlinConverting { public let timestamp: com.google.firebase.Timestamp public init(timestamp: com.google.firebase.Timestamp) { @@ -193,8 +193,34 @@ public class Timestamp: Hashable, KotlinConverting Any? { @@ -236,3 +262,34 @@ public func deepSwift(collection: kotlin.collections.Collection) -> Array< #endif #endif + +#if SKIP_BRIDGE +// The generated Timestamp bridge class declares Codable but cannot auto-synthesize it +// (JObject peer is not Codable, and encode/decode methods are not JNI-bridgeable). +// This extension satisfies the Codable conformance using the JNI-bridged properties. +extension Timestamp { + private enum CodingKeys: String, CodingKey { + case seconds = "__fts__" + case nanoseconds = "__ftn__" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(seconds, forKey: .seconds) + try container.encode(nanoseconds, forKey: .nanoseconds) + } + + public convenience init(from decoder: Decoder) throws { + if let svc = try? decoder.singleValueContainer(), let interval = try? svc.decode(Double.self) { + let s = Int64(interval) + let n = Int32((interval - Double(s)) * 1_000_000_000) + self.init(seconds: s, nanoseconds: n) + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let s = try container.decode(Int64.self, forKey: .seconds) + let n = try container.decode(Int32.self, forKey: .nanoseconds) + self.init(seconds: s, nanoseconds: n) + } +} +#endif diff --git a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift index 69c4be3..76b3e84 100644 --- a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift +++ b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift @@ -10,8 +10,234 @@ import kotlinx.coroutines.tasks.await public typealias Timestamp = SkipFirebaseCore.Timestamp +// MARK: - GeoPoint + +public final class GeoPoint: Hashable, Codable, KotlinConverting { + public let latitude: Double + public let longitude: Double + + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + // SKIP @nooverride + public override func kotlin(nocopy: Bool = false) -> com.google.firebase.firestore.GeoPoint { + return com.google.firebase.firestore.GeoPoint(latitude, longitude) + } + + public var description: String { "GeoPoint(\(latitude), \(longitude))" } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(latitude) + hasher.combine(longitude) + } + + private enum CodingKeys: String, CodingKey { + case latitude = "__fgp_lat__" + case longitude = "__fgp_lng__" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(latitude, forKey: .latitude) + try container.encode(longitude, forKey: .longitude) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.latitude = try container.decode(Double.self, forKey: .latitude) + self.longitude = try container.decode(Double.self, forKey: .longitude) + } +} + + +// MARK: - Firestore-aware deep conversion (adds GeoPoint on top of Core's deepSwift) + +fileprivate func firestoreDeepSwift(value: Any?) -> Any? { + guard let value = value else { return nil } + if let str = value as? String { return str } + if let ts = value as? com.google.firebase.Timestamp { + return Timestamp(timestamp: ts) + } + if let gp = value as? com.google.firebase.firestore.GeoPoint { + return GeoPoint(latitude: gp.latitude, longitude: gp.longitude) + } + if let map = value as? kotlin.collections.Map { + return firestoreDeepSwift(map: map) + } + if let collection = value as? kotlin.collections.Collection { + return firestoreDeepSwift(collection: collection) + } + return value +} + +fileprivate func firestoreDeepSwift(map: kotlin.collections.Map) -> Dictionary { + var dict = Dictionary() + for (key, val) in map { + if let v = firestoreDeepSwift(value: val) { + dict[key] = v + } + } + return dict +} + +fileprivate func firestoreDeepSwift(collection: kotlin.collections.Collection) -> [Any] { + var array = [Any]() + for val in collection { + if let v = firestoreDeepSwift(value: val) { + array.append(v) + } + } + return array +} + +// MARK: - Codable property wrappers + +extension CodingUserInfoKey { + static let firestoreDocumentID = CodingUserInfoKey(rawValue: "__firestore_document_id_key__")! +} + +private enum _DocumentIDCodingKey: String, CodingKey { + case value = "__document_id__" +} + +/// Marks a `String?` property to be populated with the Firestore document ID during decoding. +/// The property is not written back to the document on encode. +/// Usage: `@DocumentID var id: String?` +// SKIP REPLACE: @Target(AnnotationTarget.FIELD) @Retention(AnnotationRetention.RUNTIME) annotation class DocumentID +@propertyWrapper +public struct DocumentID: Codable { + public var wrappedValue: Value? + + public init(wrappedValue: Value? = nil) { + self.wrappedValue = wrappedValue + } + + public func encode(to encoder: Encoder) throws { + // document ID is not stored in the document data + } + + public init(from decoder: Decoder) throws { + // Primary: read from userInfo (set by FirestoreDecoder.decode(from:documentID:)) + if let id = decoder.userInfo[CodingUserInfoKey.firestoreDocumentID] as? String { + wrappedValue = id as? Value + return + } + // Fallback: read the __document_id__ key injected into the top-level dict + // This path is taken when decoder.userInfo is not available (e.g. some Skip runtimes) + if let container = try? decoder.container(keyedBy: _DocumentIDCodingKey.self), + let id = try? container.decodeIfPresent(String.self, forKey: .value) { + wrappedValue = id as? Value + return + } + wrappedValue = nil + } +} + +private struct _ServerTimestampSentinel: Encodable { + private enum CodingKeys: String, CodingKey { case marker = "__fts_server__" } + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(true, forKey: .marker) + } +} + +/// Marks a `Timestamp?` property so that a nil value is written as `FieldValue.serverTimestamp()`. +/// A non-nil value is written as the actual `Timestamp`. +/// Usage: `@ServerTimestamp var createdAt: Timestamp?` +// SKIP REPLACE: @Target(AnnotationTarget.FIELD) @Retention(AnnotationRetention.RUNTIME) annotation class ServerTimestamp +@propertyWrapper +public struct ServerTimestamp: Codable { + public var wrappedValue: Timestamp? + + public init(wrappedValue: Timestamp? = nil) { + self.wrappedValue = wrappedValue + } + + public func encode(to encoder: Encoder) throws { + if let ts = wrappedValue { + try ts.encode(to: encoder) + } else { + try _ServerTimestampSentinel().encode(to: encoder) + } + } + + public init(from decoder: Decoder) throws { + wrappedValue = try? Timestamp(from: decoder) + } +} + +// MARK: - Cache settings + +public let FirestoreCacheSizeUnlimited: Int64 = -1 + +public protocol FirestoreCacheSettings {} + +public protocol MemoryGarbageCollectionSettings {} + +public struct MemoryLRUGCSettings: MemoryGarbageCollectionSettings { + public var cacheSize: Int64 + public init(cacheSize: Int64 = FirestoreCacheSizeUnlimited) { + self.cacheSize = cacheSize + } +} + +public struct MemoryEagerGCSettings: MemoryGarbageCollectionSettings { + public init() {} +} + +public struct MemoryCacheSettings: FirestoreCacheSettings { + public var garbageCollectionSettings: MemoryGarbageCollectionSettings + public init(garbageCollectionSettings: MemoryGarbageCollectionSettings = MemoryLRUGCSettings()) { + self.garbageCollectionSettings = garbageCollectionSettings + } +} + +public struct PersistentCacheSettings: FirestoreCacheSettings { + public var sizeBytes: Int64 + public init(sizeBytes: Int64 = FirestoreCacheSizeUnlimited) { + self.sizeBytes = sizeBytes + } +} + +// MARK: - FirestoreSettings + +public class FirestoreSettings { + public var host: String = "firestore.googleapis.com" + public var isSSLEnabled: Bool = true + public var cacheSettings: FirestoreCacheSettings = PersistentCacheSettings() + + public init() {} + + func toAndroid() -> com.google.firebase.firestore.FirebaseFirestoreSettings { + let builder = com.google.firebase.firestore.FirebaseFirestoreSettings.Builder() + _ = builder.setHost(host) + _ = builder.setSslEnabled(isSSLEnabled) + if let persistent = cacheSettings as? PersistentCacheSettings { + let cacheBuilder = com.google.firebase.firestore.PersistentCacheSettings.newBuilder() + if persistent.sizeBytes != FirestoreCacheSizeUnlimited { + _ = cacheBuilder.setSizeBytes(persistent.sizeBytes) + } + _ = builder.setLocalCacheSettings(cacheBuilder.build()) + } else if cacheSettings is MemoryCacheSettings { + let cacheBuilder = com.google.firebase.firestore.MemoryCacheSettings.newBuilder() + _ = builder.setLocalCacheSettings(cacheBuilder.build()) + } + return builder.build() + } +} + +// https://firebase.google.com/docs/reference/swift/firebasefirestore/api/reference/Classes/Firestore +// https://firebase.google.com/docs/reference/android/com/google/firebase/firestore/FirebaseFirestore + public final class Firestore: KotlinConverting { public let store: com.google.firebase.firestore.FirebaseFirestore + private var _settings: FirestoreSettings = FirestoreSettings() public init(store: com.google.firebase.firestore.FirebaseFirestore) { self.store = store @@ -26,6 +252,14 @@ public final class Firestore: KotlinConverting Firestore { return Firestore(store: com.google.firebase.firestore.FirebaseFirestore.getInstance(app.app, database)) } @@ -62,11 +296,6 @@ public final class Firestore: KotlinConverting Query? { guard let query = store.getNamedQuery(name).await() else { return nil @@ -221,7 +450,7 @@ public class Filter: Equatable, KotlinConverting Bool { lhs.meta == rhs.meta } @@ -514,7 +747,6 @@ public class Query: KotlinConverting { } public class CollectionReference : Query { - //public let ref: com.google.firebase.firestore.CollectionReference public var ref: com.google.firebase.firestore.CollectionReference { self.query as! com.google.firebase.firestore.CollectionReference } @@ -556,6 +788,17 @@ public class CollectionReference : Query { throw asNSError(firestoreException: error) } } + + public func addDocument(data: [String: Any], completion: @escaping (Error?) -> Void) { + ref.add(data.kotlin()) + .addOnSuccessListener { _ in completion(nil) } + .addOnFailureListener { exception in completion(ErrorException(exception)) } + } + + public func addDocument(from value: T) async throws -> DocumentReference { + let data = try FirestoreEncoder().encode(value) + return try await addDocument(data: data) + } } public class ListenerRegistration: KotlinConverting { @@ -747,7 +990,7 @@ public class AggregateQuerySnapshot: KotlinConverting [String: Any]? { if let data = doc.getData() { - return deepSwift(map: data) + return firestoreDeepSwift(map: data) } else { return nil } @@ -833,7 +1084,25 @@ public class DocumentSnapshot: KotlinConverting Any? { + guard let value = doc.get(fieldPath.fieldPath) else { + return nil + } + return firestoreDeepSwift(value: value) + } + + // NOTE: use return-type inference on Android: + // let model: MyModel = try snapshot.decoded() + // On iOS use FirebaseFirestoreSwift's data(as: MyModel.self) instead. + // SKIP DECLARE: public inline fun decoded(): T + public func decoded() throws -> T { + guard let dict = data() else { + throw NSError(domain: FirestoreErrorDomain, code: FirestoreErrorCode.notFound.rawValue, userInfo: [NSLocalizedDescriptionKey: "Document does not exist"]) + } + return try FirestoreDecoder().decode(from: dict, documentID: documentID) } } @@ -846,13 +1115,13 @@ public class QueryDocumentSnapshot : DocumentSnapshot { super.init(doc: snapshot) } - public var reference: DocumentReference { + override public var reference: DocumentReference { DocumentReference(ref: snapshot.reference) } override public func data() -> [String: Any] { if let data = doc.getData() { - return deepSwift(map: data) + return firestoreDeepSwift(map: data) } else { return [:] } @@ -892,6 +1161,15 @@ public class DocumentReference: KotlinConverting DocumentSnapshot { + do { + let snapshot = try ref.get(source.source).await() + return DocumentSnapshot(doc: snapshot) + } catch is com.google.firebase.firestore.FirebaseFirestoreException { + throw asNSError(firestoreException: error) + } + } + public func getDocument(completion: @escaping (_ snapshot: DocumentSnapshot?, _ error: (any Error)?) -> Void) { ref.get().addOnSuccessListener { documentSnapshot in completion(DocumentSnapshot(doc: documentSnapshot), nil) @@ -933,6 +1211,24 @@ public class DocumentReference: KotlinConverting(from value: T, merge: Bool = false) async throws { + let data = try FirestoreEncoder().encode(value) + try await setData(data, merge: merge) + } + + public func setData(from value: T, mergeFields: [String]) async throws { + let data = try FirestoreEncoder().encode(value) + try await setData(data, mergeFields: mergeFields) + } + public func updateData(_ keyValues: [String: Any]) async throws { do { try ref.update(keyValues.kotlin() as! Map).await() @@ -996,6 +1292,16 @@ public class WriteBatch { let newBatch = batch.update(document.ref, fields.kotlin() as! Map) return WriteBatch(batch: newBatch) } + + public func setData(from value: T, forDocument document: DocumentReference) throws -> WriteBatch { + let data = try FirestoreEncoder().encode(value) + return setData(data, forDocument: document) + } + + public func setData(from value: T, forDocument document: DocumentReference, mergeFields: [String]) throws -> WriteBatch { + let data = try FirestoreEncoder().encode(value) + return setData(data, forDocument: document, mergeFields: mergeFields) + } } public class FieldValue { @@ -1058,5 +1364,192 @@ fileprivate func asNSError(firestoreException: com.google.firebase.firestore.Fir return NSError(domain: FirestoreErrorDomain, code: firestoreException.code.value(), userInfo: userInfo) } +// MARK: - Codable support + +/// Encodes a `Codable` value into a `[String: Any]` Firestore document map. +/// `Timestamp` and `GeoPoint` fields are preserved as native Firestore types. +/// Note: use `Timestamp` (not `Date`) in your model for Firestore timestamp fields. +public class FirestoreEncoder { + public init() {} + + public func encode(_ value: T) throws -> [String: Any] { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, enc in + var container = enc.container(keyedBy: _FirestoreDateSentinelKeys.self) + try container.encode(date.timeIntervalSince1970, forKey: .marker) + } + let data = try encoder.encode(value) + let json = try JSONSerialization.jsonObject(with: data) + guard let dict = json as? [String: Any] else { + throw NSError(domain: FirestoreErrorDomain, code: FirestoreErrorCode.invalidArgument.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Top-level encoded value must be a dictionary"]) + } + var result = restoreFirestoreTypes(dict) as! [String: Any] + /* SKIP INSERT: + for (field in value.javaClass.declaredFields) { + if (field.isAnnotationPresent(ServerTimestamp::class.java)) { + field.isAccessible = true + val fieldVal = try { field.get(value) } catch (e: Exception) { null } + if (fieldVal == null) { + result[field.name] = com.google.firebase.firestore.FieldValue.serverTimestamp() + } + } + } + */ + return result + } + + private func restoreFirestoreTypes(_ value: Any) -> Any { + if let dict = value as? [String: Any] { + // @ServerTimestamp nil sentinel → FieldValue.serverTimestamp() + if dict.count == 1, let _ = dict["__fts_server__"] { + return com.google.firebase.firestore.FieldValue.serverTimestamp() + } + // Date sentinel → Timestamp + if dict.count == 1, let t = dict["__firestore_date__"] { + let interval = coerceDouble(t) ?? 0.0 + return Timestamp(date: Date(timeIntervalSince1970: interval)) + } + // Timestamp sentinel → Timestamp + if dict.count == 2, let s = dict["__fts__"], let n = dict["__ftn__"] { + let seconds = coerceInt64(s) ?? Int64(0) + let nanos = coerceInt32(n) ?? Int32(0) + return Timestamp(seconds: seconds, nanoseconds: nanos) + } + // GeoPoint sentinel → GeoPoint + if dict.count == 2, let lat = dict["__fgp_lat__"], let lng = dict["__fgp_lng__"] { + return GeoPoint(latitude: coerceDouble(lat) ?? 0.0, longitude: coerceDouble(lng) ?? 0.0) + } + var result = [String: Any]() + for (key, val) in dict { + result[key] = restoreFirestoreTypes(val) + } + return result + } + if let array = value as? [Any] { + return array.map { restoreFirestoreTypes($0) } + } + return value + } +} + +private enum _FirestoreDateSentinelKeys: String, CodingKey { + case marker = "__firestore_date__" +} + +/// Decodes a `Codable` value from a `[String: Any]` Firestore document map. +/// `Timestamp` fields decode as either `Timestamp` or `Date` (via `.secondsSince1970`). +/// Pass `documentID` to populate `@DocumentID`-annotated properties. +public class FirestoreDecoder { + public init() {} + + // SKIP DECLARE: public inline fun decode(from: Dictionary): T + public func decode(from data: [String: Any]) throws -> T { + return try decode(from: data, documentID: nil) + } + + // SKIP DECLARE: public inline fun decode(from: Dictionary, documentID: String?): T + public func decode(from data: [String: Any], documentID: String?) throws -> T { + var prepared = prepareForJSON(data) as! [String: Any] + if let id = documentID { + prepared["__document_id__"] = id + } + let jsonData = try JSONSerialization.data(withJSONObject: prepared) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + if let id = documentID { + // SKIP REPLACE: decoder.userInfo[CodingUserInfoKey(rawValue = "__firestore_document_id_key__")!!] = id + decoder.userInfo[CodingUserInfoKey.firestoreDocumentID] = id + } + /* SKIP INSERT: + val result = decoder.decode(T::class, from = jsonData) + if (documentID != null) { + for (field in result.javaClass.declaredFields) { + if (field.isAnnotationPresent(DocumentID::class.java)) { + field.isAccessible = true + try { field.set(result, documentID) } catch (e: Exception) { } + } + } + } + return result + */ + // SKIP REPLACE: + return try decoder.decode(T.self, from: jsonData) + } + + public func prepareForJSON(_ value: Any?) -> Any { + guard let value = value else { return NSNull() } + // Convert Timestamp to timeInterval Double so both Date and Timestamp fields decode correctly. + // Timestamp.init(from:) handles Double via its singleValueContainer path. + if let ts = value as? Timestamp { + return ts.seconds + Double(ts.nanoseconds) / 1_000_000_000.0 + } + if let gp = value as? GeoPoint { + return ["__fgp_lat__": gp.latitude, "__fgp_lng__": gp.longitude] + } + if let dict = value as? [String: Any] { + var result = [String: Any]() + for (key, val) in dict { + result[key] = prepareForJSON(val) + } + return result + } + if let array = value as? [Any] { + return array.map { prepareForJSON($0) } + } + return value + } +} + +// MARK: - JSON numeric coercion helpers + +fileprivate func coerceInt64(_ v: Any) -> Int64? { + if let x = v as? Int64 { return x } + if let x = v as? Int { return Int64(x) } + if let x = v as? Int32 { return Int64(x) } + if let x = v as? Double { return Int64(x) } + return nil +} + +fileprivate func coerceInt32(_ v: Any) -> Int32? { + if let x = v as? Int32 { return x } + if let x = v as? Int64 { return Int32(x) } + if let x = v as? Int { return Int32(x) } + if let x = v as? Double { return Int32(x) } + return nil +} + +fileprivate func coerceDouble(_ v: Any) -> Double? { + if let x = v as? Double { return x } + if let x = v as? Int64 { return Double(x) } + if let x = v as? Int { return Double(x) } + return nil +} + #endif #endif + +#if SKIP_BRIDGE +// The generated GeoPoint bridge class declares Codable but cannot auto-synthesize it +// (JObject peer is not Codable, and encode/decode methods are not JNI-bridgeable). +// This extension satisfies the Codable conformance using the JNI-bridged properties. +extension GeoPoint { + private enum CodingKeys: String, CodingKey { + case latitude = "__fgp_lat__" + case longitude = "__fgp_lng__" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(latitude, forKey: .latitude) + try container.encode(longitude, forKey: .longitude) + } + + public convenience init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let lat = try container.decode(Double.self, forKey: .latitude) + let lng = try container.decode(Double.self, forKey: .longitude) + self.init(latitude: lat, longitude: lng) + } +} +#endif diff --git a/Tests/SkipFirebaseFirestoreTests/SkipFirebaseFirestoreTests.swift b/Tests/SkipFirebaseFirestoreTests/SkipFirebaseFirestoreTests.swift index caf343f..64112b6 100644 --- a/Tests/SkipFirebaseFirestoreTests/SkipFirebaseFirestoreTests.swift +++ b/Tests/SkipFirebaseFirestoreTests/SkipFirebaseFirestoreTests.swift @@ -428,3 +428,134 @@ extension SkipFirebaseFirestoreTests { struct TestData : Codable, Hashable { var testModuleName: String } + +// MARK: - Encoder/decoder unit tests (Android/Skip only — FirestoreEncoder/Decoder are Skip-only types) + +// Inline structs must be file-level in Skip (no type declarations inside functions) +#if SKIP +struct CodableCity: Codable, Equatable { + @DocumentID var id: String? + var name: String + var population: Int64 + var active: Bool + var score: Double + var createdAt: Date? + var updatedAt: Timestamp? + @ServerTimestamp var serverTime: Timestamp? +} + +struct _MString: Codable { var name: String } +struct _MBool: Codable { var active: Bool } +struct _MDate: Codable { var createdAt: Date } +struct _MTimestamp: Codable { var ts: Timestamp } +struct _MServerTimestamp: Codable { @ServerTimestamp var ts: Timestamp? } +struct _MDocumentID: Codable { @DocumentID var id: String?; var name: String } + +@MainActor final class FirestoreCodecTests: XCTestCase { + + func testEncoderPreservesString() throws { + let out = try FirestoreEncoder().encode(_MString(name: "Boston")) + XCTAssertEqual(out["name"] as? String, "Boston") + } + + func testEncoderPreservesBoolTrue() throws { + let out = try FirestoreEncoder().encode(_MBool(active: true)) + XCTAssertEqual(out["active"] as? Bool, true) + } + + func testEncoderPreservesBoolFalse() throws { + let out = try FirestoreEncoder().encode(_MBool(active: false)) + XCTAssertEqual(out["active"] as? Bool, false) + } + + func testEncoderConvertsDateToTimestamp() throws { + let date = Date(timeIntervalSince1970: 1_234_567_890) + let out = try FirestoreEncoder().encode(_MDate(createdAt: date)) + let ts = try XCTUnwrap(out["createdAt"] as? Timestamp) + XCTAssertEqual(ts.seconds, 1_234_567_890) + XCTAssertEqual(ts.nanoseconds, 0) + } + + func testEncoderPreservesTimestampField() throws { + let ts = Timestamp(seconds: 999, nanoseconds: 42) + let out = try FirestoreEncoder().encode(_MTimestamp(ts: ts)) + let result = try XCTUnwrap(out["ts"] as? Timestamp) + XCTAssertEqual(result.seconds, 999) + XCTAssertEqual(result.nanoseconds, 42) + } + + func testEncoderServerTimestampNilBecomesFieldValue() throws { + let out = try FirestoreEncoder().encode(_MServerTimestamp()) + XCTAssertTrue(out["ts"] is com.google.firebase.firestore.FieldValue) + } + + func testEncoderServerTimestampNonNilPreservesTimestamp() throws { + let ts = Timestamp(seconds: 1000, nanoseconds: 0) + let out = try FirestoreEncoder().encode(_MServerTimestamp(ts: ts)) + let result = try XCTUnwrap(out["ts"] as? Timestamp) + XCTAssertEqual(result.seconds, 1000) + } + + func testEncoderDocumentIDNotEncoded() throws { + let out = try FirestoreEncoder().encode(_MDocumentID(name: "Test")) + XCTAssertNil(out["id"]) + XCTAssertEqual(out["name"] as? String, "Test") + } + + func testDecoderTimestampToDateField() throws { + let ts = Timestamp(seconds: 1_234_567_890, nanoseconds: 0) + let dict: [String: Any] = ["createdAt": ts] + let m: _MDate = try FirestoreDecoder().decode(from: dict) + XCTAssertEqual(m.createdAt.timeIntervalSince1970, 1_234_567_890, accuracy: 1.0) + } + + func testDecoderTimestampToTimestampField() throws { + let ts = Timestamp(seconds: 999, nanoseconds: 42) + let dict: [String: Any] = ["ts": ts] + let m: _MTimestamp = try FirestoreDecoder().decode(from: dict) + XCTAssertEqual(m.ts.seconds, 999) + XCTAssertEqual(m.ts.nanoseconds, 42) + } + + func testDecoderDocumentIDPopulatedWhenProvided() throws { + let dict: [String: Any] = ["name": "Boston"] + let m: _MDocumentID = try FirestoreDecoder().decode(from: dict, documentID: "BOS") + XCTAssertEqual(m.id, "BOS") + XCTAssertEqual(m.name, "Boston") + } + + func testDecoderDocumentIDNilWhenNotProvided() throws { + let dict: [String: Any] = ["name": "Boston"] + let m: _MDocumentID = try FirestoreDecoder().decode(from: dict) + XCTAssertNil(m.id) + XCTAssertEqual(m.name, "Boston") + } + + func testDecoderDocumentIDTakesFromRef_NotFromData() throws { + let dict: [String: Any] = ["name": "Boston", "id": "FROM_DATA"] + let m: _MDocumentID = try FirestoreDecoder().decode(from: dict, documentID: "FROM_REF") + XCTAssertEqual(m.id, "FROM_REF") + } + + func testRoundtripCodableCity() throws { + let city = CodableCity( + name: "Boston", + population: 675_000, + active: true, + score: 9.5, + createdAt: Date(timeIntervalSince1970: 1_000_000), + updatedAt: Timestamp(seconds: 2_000_000, nanoseconds: 0), + serverTime: Timestamp(seconds: 3_000_000, nanoseconds: 0) + ) + let encoded = try FirestoreEncoder().encode(city) + let decoded: CodableCity = try FirestoreDecoder().decode(from: encoded, documentID: "BOS") + XCTAssertEqual(decoded.id, "BOS") + XCTAssertEqual(decoded.name, "Boston") + XCTAssertEqual(decoded.population, 675_000) + XCTAssertEqual(decoded.active, true) + XCTAssertEqual(decoded.score, 9.5, accuracy: 0.001) + XCTAssertEqual(decoded.updatedAt?.seconds, 2_000_000) + XCTAssertEqual(decoded.serverTime?.seconds, 3_000_000) + } +} +#endif