From 70fbb1fd2ebc146d6f04142adac40d2fedf590b8 Mon Sep 17 00:00:00 2001 From: Jeroen <76699269+IT-Guy007@users.noreply.github.com> Date: Fri, 15 May 2026 18:58:48 +0200 Subject: [PATCH 1/5] FirebaseFirestore improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkipFirebaseCore.swift - Timestamp now conforms to Codable using internal sentinel keys (__fts__/__ftn__) for JSON round-tripping SkipFirebaseFirestore.swift - GeoPoint class — Hashable, KotlinConverting (so it survives .kotlin() dict conversion), Codable - firestoreDeepSwift — replaces deepSwift calls throughout to also handle com.google.firebase.firestore.GeoPoint → GeoPoint - FirestoreCacheSizeUnlimited, FirestoreCacheSettings, MemoryGarbageCollectionSettings, MemoryLRUGCSettings, MemoryEagerGCSettings, MemoryCacheSettings, PersistentCacheSettings - FirestoreSettings class with host, isSSLEnabled, cacheSettings and toAndroid() builder - Firestore.settings get/set property — applies to the Android store on set - SnapshotMetadata.isFromCache - DocumentSnapshot.metadata, .reference, .get(_ fieldPath: FieldPath), .data() throws -> T - DocumentReference.getDocument(source:) async, setData(_:mergeFields:) async, setData(from:merge:) async, setData(from:mergeFields:) async - CollectionReference.addDocument(data:completion:) - FirestoreEncoder and FirestoreDecoder classes One API note: Skip can't bridge T.Type parameters, so on Android the Codable decode is let m: MyModel = try snapshot.data() (return-type inference). On iOS you use FirebaseFirestoreSwift's snapshot.data(as: MyModel.self) as normal. The encode direction (setData(from:)) works identically on both platforms. --- README.md | 5 +- .../SkipFirebaseCore/SkipFirebaseCore.swift | 20 +- .../SkipFirebaseFirestore.swift | 338 +++++++++++++++++- 3 files changed, 348 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e83b29c..21323ba 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`** lacks `async`/`await` call signatures, streaming RPCs, and per-call options. Only completion-based callback calls are supported. - **`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..cfaf392 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 class Timestamp: Hashable, KotlinConverting, Codable { public let timestamp: com.google.firebase.Timestamp public init(timestamp: com.google.firebase.Timestamp) { @@ -193,6 +193,24 @@ public class Timestamp: Hashable, KotlinConverting, Codable { + 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: - 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 +175,14 @@ public final class Firestore: KotlinConverting Firestore { return Firestore(store: com.google.firebase.firestore.FirebaseFirestore.getInstance(app.app, database)) } @@ -62,11 +219,6 @@ public final class Firestore: KotlinConverting Query? { guard let query = store.getNamedQuery(name).await() else { return nil @@ -221,7 +373,7 @@ public class Filter: Equatable, KotlinConverting Bool { lhs.meta == rhs.meta } @@ -514,7 +670,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 +711,12 @@ 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 class ListenerRegistration: KotlinConverting { @@ -747,7 +908,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 +1002,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) } } @@ -846,13 +1033,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 +1079,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 +1129,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() @@ -1058,5 +1272,105 @@ 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 data = try JSONEncoder().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"]) + } + return restoreFirestoreTypes(dict) as! [String: Any] + } + + private func restoreFirestoreTypes(_ value: Any) -> Any { + if let dict = value as? [String: Any] { + 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) + } + 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 + } +} + +/// Decodes a `Codable` value from a `[String: Any]` Firestore document map. +/// `Timestamp` and `GeoPoint` values in the map are round-tripped correctly. +public class FirestoreDecoder { + public init() {} + + // SKIP DECLARE: public inline fun decode(from: Dictionary): T + public func decode(from data: [String: Any]) throws -> T { + let prepared = prepareForJSON(data) + let jsonData = try JSONSerialization.data(withJSONObject: prepared) + return try JSONDecoder().decode(T.self, from: jsonData) + } + + public func prepareForJSON(_ value: Any?) -> Any { + guard let value = value else { return NSNull() } + if let ts = value as? Timestamp { + return ["__fts__": ts.seconds, "__ftn__": ts.nanoseconds] + } + 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 From e206c1af05d87d55a6414f89deff7651bb2be424 Mon Sep 17 00:00:00 2001 From: Jeroen <76699269+IT-Guy007@users.noreply.github.com> Date: Fri, 15 May 2026 23:05:14 +0200 Subject: [PATCH 2/5] Skip Firebase Firestore improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkipFirebaseCore.swift - Timestamp.init(from:) now handles a plain Double (seconds since epoch) in addition to the existing {"__fts__"/"__ftn__"} sentinel dict — enables Timestamp fields in Codable models to decode from the timeInterval doubles emitted by the new decoder path SkipFirebaseFirestore.swift Property wrappers (new): - @DocumentID — no-op on encode; on decode reads from decoder.userInfo[.firestoreDocumentID] (set by the decoder when a documentID is provided), with a keyed-container fallback for Skip runtimes where userInfo may not propagate - @ServerTimestamp — on encode: nil → {"__fts_server__": true} sentinel (restored to FieldValue.serverTimestamp() by the encoder pass); non-nil → actual Timestamp. On decode: reads Timestamp back normally - CodingUserInfoKey.firestoreDocumentID extension - _DocumentIDCodingKey and _FirestoreDateSentinelKeys private enum helpers FirestoreEncoder improvements: - Date → {"__firestore_date__": timeInterval} sentinel via custom dateEncodingStrategy, restored to Timestamp in restoreFirestoreTypes - {"__fts_server__": true} → FieldValue.serverTimestamp() in restoreFirestoreTypes - NSNumber Bool coercion in restoreFirestoreTypes (Android JSONSerialization returns 0/1 instead of native Bool) FirestoreDecoder improvements: - New decode(from:documentID:) overload — injects document ID into userInfo, sets dateDecodingStrategy = .secondsSince1970 - prepareForJSON now converts Timestamp → Double (timeInterval) instead of sentinel dict, enabling both Date and Timestamp model fields to decode correctly - NSNumber Bool coercion in prepareForJSON - DocumentSnapshot.decoded() now passes documentID to the decoder New Codable overloads: - CollectionReference.addDocument(from: T) async throws - WriteBatch.setData(from: T, forDocument:) throws → WriteBatch (chainable) - WriteBatch.setData(from: T, forDocument:mergeFields:) throws → WriteBatch --- .../SkipFirebaseCore/SkipFirebaseCore.swift | 7 + .../SkipFirebaseFirestore.swift | 167 +++++++++++++++++- .../SkipFirebaseFirestoreTests.swift | 131 ++++++++++++++ 3 files changed, 298 insertions(+), 7 deletions(-) diff --git a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift index cfaf392..9412ccc 100644 --- a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift +++ b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift @@ -206,6 +206,13 @@ public class Timestamp: Hashable, KotlinConverting: 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 @@ -717,6 +793,11 @@ public class CollectionReference : Query { .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 { @@ -1020,7 +1101,7 @@ public class DocumentSnapshot: KotlinConverting) 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 { @@ -1281,22 +1372,50 @@ public class FirestoreEncoder { public init() {} public func encode(_ value: T) throws -> [String: Any] { - let data = try JSONEncoder().encode(value) + 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"]) } - return restoreFirestoreTypes(dict) as! [String: Any] + 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) } @@ -1313,22 +1432,56 @@ public class FirestoreEncoder { } } +private enum _FirestoreDateSentinelKeys: String, CodingKey { + case marker = "__firestore_date__" +} + /// Decodes a `Codable` value from a `[String: Any]` Firestore document map. -/// `Timestamp` and `GeoPoint` values in the map are round-tripped correctly. +/// `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 { - let prepared = prepareForJSON(data) + 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) - return try JSONDecoder().decode(T.self, from: jsonData) + 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 ["__fts__": ts.seconds, "__ftn__": ts.nanoseconds] + 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] 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 From 969b83485d2332ffb399883fc3228e11bf9523a6 Mon Sep 17 00:00:00 2001 From: Jeroen <76699269+IT-Guy007@users.noreply.github.com> Date: Sat, 16 May 2026 12:49:32 +0200 Subject: [PATCH 3/5] Move Codable conformance to extensions Remove Codable from the main class declarations for Timestamp and GeoPoint and add separate extension-based Codable conformance. This prevents the Skip bridge generator from including Codable in the generated bridge class (which has a JObject peer that cannot auto-synthesize Codable); the existing encode/decode implementations already satisfy the conformance. Changes made in SkipFirebaseCore.swift and SkipFirebaseFirestore.swift. --- Sources/SkipFirebaseCore/SkipFirebaseCore.swift | 8 +++++++- Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift index 9412ccc..e7b76ea 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, Codable { +public class Timestamp: Hashable, KotlinConverting { public let timestamp: com.google.firebase.Timestamp public init(timestamp: com.google.firebase.Timestamp) { @@ -220,6 +220,12 @@ public class Timestamp: Hashable, KotlinConverting Any? { diff --git a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift index 2ba21f3..e1ec438 100644 --- a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift +++ b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift @@ -12,7 +12,7 @@ public typealias Timestamp = SkipFirebaseCore.Timestamp // MARK: - GeoPoint -public final class GeoPoint: Hashable, KotlinConverting, Codable { +public final class GeoPoint: Hashable, KotlinConverting { public let latitude: Double public let longitude: Double @@ -55,6 +55,12 @@ public final class GeoPoint: Hashable, KotlinConverting Any? { From 87091c076ef9560f09c4752ace935e59e6b735fe Mon Sep 17 00:00:00 2001 From: Jeroen <76699269+IT-Guy007@users.noreply.github.com> Date: Sat, 16 May 2026 16:48:45 +0200 Subject: [PATCH 4/5] Add SwiftCustomBridged to Timestamp and GeoPoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skip bridge generator includes ALL protocol conformances (both class declarations and extensions) when generating bridge classes. Moving Codable to an extension was insufficient. SwiftCustomBridged is the correct signal — used by skip-foundation's Date, Data, UUID, and URL for the same reason — to tell the bridge generator that these types handle their own bridging rather than auto-generating a standard bridge class that would fail to synthesize Codable with a JObject peer. --- .../SkipFirebaseCore/SkipFirebaseCore.swift | 38 ++++++++++++++++--- .../SkipFirebaseFirestore.swift | 32 +++++++++++++--- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift index e7b76ea..3ced950 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, SwiftCustomBridged { public let timestamp: com.google.firebase.Timestamp public init(timestamp: com.google.firebase.Timestamp) { @@ -220,11 +220,6 @@ public class Timestamp: Hashable, KotlinConverting) -> 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 e1ec438..5571912 100644 --- a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift +++ b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift @@ -12,7 +12,7 @@ public typealias Timestamp = SkipFirebaseCore.Timestamp // MARK: - GeoPoint -public final class GeoPoint: Hashable, KotlinConverting { +public final class GeoPoint: Hashable, Codable, KotlinConverting, SwiftCustomBridged { public let latitude: Double public let longitude: Double @@ -55,11 +55,6 @@ public final class GeoPoint: Hashable, KotlinConverting Double? { #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 From 5fc2da0d89f1bc1656750f1c25de3f38bf49de2f Mon Sep 17 00:00:00 2001 From: Jeroen <76699269+IT-Guy007@users.noreply.github.com> Date: Sat, 16 May 2026 18:55:44 +0200 Subject: [PATCH 5/5] Remove SwiftCustomBridged from Timestamp and GeoPoint SwiftCustomBridged is defined in SkipLib, but the generated bridge file only imports SkipBridge and Foundation, so the protocol is not in scope during the SKIP_BRIDGE compilation and causes a build error. --- Sources/SkipFirebaseCore/SkipFirebaseCore.swift | 2 +- Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SkipFirebaseCore/SkipFirebaseCore.swift b/Sources/SkipFirebaseCore/SkipFirebaseCore.swift index 3ced950..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 final class Timestamp: Hashable, Codable, KotlinConverting, SwiftCustomBridged { +public final class Timestamp: Hashable, Codable, KotlinConverting { public let timestamp: com.google.firebase.Timestamp public init(timestamp: com.google.firebase.Timestamp) { diff --git a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift index 5571912..76b3e84 100644 --- a/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift +++ b/Sources/SkipFirebaseFirestore/SkipFirebaseFirestore.swift @@ -12,7 +12,7 @@ public typealias Timestamp = SkipFirebaseCore.Timestamp // MARK: - GeoPoint -public final class GeoPoint: Hashable, Codable, KotlinConverting, SwiftCustomBridged { +public final class GeoPoint: Hashable, Codable, KotlinConverting { public let latitude: Double public let longitude: Double