Newer Older
325 lines | 12.638kb
Bogdan Timofte authored 2 weeks ago
1
# CloudKit Sync Mechanism
2

            
3
## Architecture Overview
4

            
5
```
6
┌──────────────────────────────────────────────────────────────┐
7
│                        App Layer                            │
8
│  Meter.swift (UI)  ←→  BluetoothManager.swift (BT comms)  │
9
└───────────┬────────────────────────────┬────────────────────┘
10
            │                            │
11
            └───────────┬────────────────┘
12
                        │
13
              ┌─────────▼──────────┐
14
              │ AppData.swift      │
15
              │  (Orchestrator)    │
16
              └──────────┬─────────┘
17
                         │
18
        ┌────────────────┼────────────────┐
19
        │                │                │
20
   ┌────▼─────┐   ┌─────▼──────┐   ┌────▼──────────┐
21
   │ CloudDeviceSettingsStore   │   │ Bluetooth    │
22
   │ (Core Data Layer)          │   │ Persistence  │
23
   └────┬─────┘                 │   └──────────────┘
24
        │                       │
25
   ┌────▼────────────────────────────┐
26
   │ NSManagedObjectContext           │
27
   │ (viewContext)                    │
28
   └────┬─────────────────────────────┘
29
        │
30
   ┌────▼──────────────────────────────────────────┐
31
   │ NSPersistentCloudKitContainer                  │
32
   │ - Automatic bi-directional sync               │
33
   │ - Handles conflicts via merge policy          │
34
   │ - Remote change notifications                │
35
   └────┬─────────────────────┬────────────────────┘
36
        │                     │
37
   ┌────▼──────┐    ┌────────▼─────────────────┐
38
   │ Local DB  │    │ CloudKit Framework       │
39
   │ SQLite    │    │ - Push notifications     │
40
   │           │    │ - Persistent history    │
41
   └───────────┘    └────────┬──────────────────┘
42
                             │
43
                        ┌────▼────────────┐
44
                        │ iCloud Server   │
45
                        │ CloudKit DB     │
46
                        └────┬────────────┘
47
                             │
48
                        (Sync'd to other devices)
49
```
50

            
51
---
52

            
53
## Sync Flow - Step by Step
54

            
55
### Local Write Flow
56

            
57
```
58
1. User Action (connect meter)
59
   │
60
2. Meter.swift → operationalState.didSet
61
   │
62
3. appData.publishMeterConnection(mac: String, modelType: String)
63
   │
64
4. CloudDeviceSettingsStore.setConnection(macAddress:, modelType:)
65
   │
66
   ├─ Fetch existing DeviceSettings for macAddress
67
   │  • If nil: create new NSManagedObject
68
   │  • If exists: update it
69
   │
70
5. Update attributes:
71
   │  • connectedByDeviceID = UIDevice.current.identifierForVendor
72
   │  • connectedByDeviceName = UIDevice.current.name
73
   │  • connectedAt = Date()
74
   │  • connectedExpiryAt = Date.now + 120 seconds
75
   │  • updatedAt = Date()
76
   │
77
6. appData.persistentContainer.viewContext.save()
78
   │
79
7. NSPersistentCloudKitContainer intercepts save:
80
   │  • Detects changes to DeviceSettings entity
81
   │  • Serializes to CKRecord ("DeviceSettings/[macAddress]")
82
   │
83
8. CloudKit framework:
84
   │  • Queues for upload (background)
85
   │  • Sends to iCloud servers
86
   │
87
9. Remote device:
88
   │  • Receives NSPersistentStoreRemoteChangeNotification
89
   │  • Automatically merges into local Core Data
90
   │  • AppData detects change → reloadSettingsFromCloudStore()
91
   │  • UI updates via @Published updates
92
```
93

            
94
---
95

            
96
### Receiving Remote Changes
97

            
98
```
99
1. iCloud Server discovers change
100
   │
101
2. Sends data to Device B via CloudKit
102
   │
103
3. iOS system fires:
104
   NSPersistentStoreRemoteChangeNotificationPostOptionKey
105
   │
106
4. AppData.setupRemoteChangeNotificationObserver():
107
   @objc private func remoteStoreDidChange() {
108
     // This runs on background thread!
109
     reloadSettingsFromCloudStore()
110
   }
111
   │
112
5. reloadSettingsFromCloudStore() fetches fresh data:
113
   │
114
   ├─ cloudStore.fetch(for: macAddress) [async per-device]
115
   │
116
6. Updates local @Published properties
117
   │  → triggers UI re-render in SwiftUI
118
   │
119
7. Meter.swift observes changes:
120
   │  → updates connection state badges
121
   │  → shows "Connected on [Device Name]" etc.
122
```
123

            
124
---
125

            
126
## AppData: Core Sync Methods
127

            
128
### Device Name Handling (AppData.myDeviceName)
129

            
130
```swift
Bogdan Timofte authored a week ago
131
static let myDeviceName: String = getDeviceName()
Bogdan Timofte authored 2 weeks ago
132

            
Bogdan Timofte authored a week ago
133
private func getDeviceName() -> String {
Bogdan Timofte authored 2 weeks ago
134
    let hostname = ProcessInfo.processInfo.hostName
135
    return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
136
}
137
```
138

            
Bogdan Timofte authored a week ago
139
**Why:** Uses hostname on all platforms (iOS, iPadOS, macOS Catalyst, iPad on Mac). Examples: "iPhone-User", "MacBook-Pro.local". Avoids platform-specific names that are irrelevant ("iPad", "iPhone" without context).
Bogdan Timofte authored 2 weeks ago
140

            
141
### `persistentContainer` (AppDelegate.swift)
142

            
143
```swift
144
lazy var persistentContainer: NSPersistentCloudKitContainer = {
145
    let container = NSPersistentCloudKitContainer(name: "CKModel")
146
    if let description = container.persistentStoreDescriptions.first {
147
        description.cloudKitContainerOptions =
148
            NSPersistentCloudKitContainerOptions(
149
                containerIdentifier: "iCloud.ro.xdev.USB-Meter"
150
            )
151
        // Enable historical tracking for conflict resolution
152
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
153
        // Enable remote change notifications
154
        description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
155
    }
156
    container.viewContext.automaticallyMergesChangesFromParent = true
157
    container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
158
    return container
159
}()
160
```
161

            
162
**Key configs:**
163
- `automaticallyMergesChangesFromParent = true`: Auto-merge CloudKit changes without explicit fetch
164
- `NSMergeByPropertyStoreTrumpMergePolicy`: CloudKit always wins on property conflicts
165

            
166
### `activateCloudDeviceSync` (SceneDelegate.swift)
167

            
168
```swift
169
appData.activateCloudDeviceSync(context: appDelegate.persistentContainer.viewContext)
170
```
171

            
172
**Does:**
173
1. Sets up remote change observers
174
2. Triggers first `reloadSettingsFromCloudStore()` to load persisted CloudKit data
175
3. Starts polling for connection expiry checks
176

            
177
### `publishMeterConnection` (AppData.swift)
178

            
179
```swift
180
public func publishMeterConnection(macAddress: String, modelType: String) {
181
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
182
        self?.cloudStore.setConnection(macAddress: macAddress, modelType: modelType)
183
    }
184
}
185
```
186

            
187
**Called from:** `Meter.swift` when `operationalState` changes to `.peripheralConnected`
188

            
189
### `clearMeterConnection` (AppData.swift)
190

            
191
```swift
192
public func clearMeterConnection(macAddress: String) {
193
    DispatchQueue.global(qos: .userInitiated).async { [weak self] in
194
        self?.cloudStore.clearConnection(macAddress: macAddress)
195
    }
196
}
197
```
198

            
199
**Called from:** `Meter.swift` when `operationalState` → `.offline` / `.peripheralNotConnected`
200

            
201
### `reloadSettingsFromCloudStore` (AppData.swift)
202

            
203
```swift
204
private func reloadSettingsFromCloudStore() {
205
    // Fetch all DeviceSettings records
206
    // Update internal @Published state
207
    // Publish changes via objectWillChange
208
}
209
```
210

            
211
**Called from:**
212
- `activateCloudDeviceSync` (initial load)
213
- Remote change notification handler (when CloudKit syncs changes)
214
- Periodic check for connection expiry
215

            
216
---
217

            
218
## CloudDeviceSettingsStore: Private Implementation
219

            
220
```swift
221
private class CloudDeviceSettingsStore {
222
    let context: NSManagedObjectContext
223
    let entityName = "DeviceSettings"
224

            
225
    func setConnection(macAddress: String, modelType: String) {
226
        context.performAndWait {
227
            // 1. Normalize MAC
228
            let mac = normalizedMACAddress(macAddress)
229

            
230
            // 2. Fetch or create
231
            let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
232
            request.predicate = NSPredicate(format: "macAddress == %@", mac)
233
            var object = try? context.fetch(request).first
234
            if object == nil {
235
                object = NSManagedObject(entity: ..., insertInto: context)
236
            }
237

            
238
            // 3. Update connection metadata
239
            object?.setValue(mac, forKey: "macAddress")
240
            object?.setValue(modelType, forKey: "modelType")
241
            object?.setValue(UIDevice.current.identifierForVendor?.uuidString, forKey: "connectedByDeviceID")
242
            object?.setValue(UIDevice.current.name, forKey: "connectedByDeviceName")
243
            object?.setValue(Date(), forKey: "connectedAt")
244
            object?.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt")
245
            object?.setValue(Date(), forKey: "updatedAt")
246

            
247
            // 4. Save → triggers CloudKit sync
248
            try? context.save()
249
        }
250
    }
251

            
252
    func fetch(for macAddress: String) -> DeviceSettingsRecord? {
253
        // Similar to setConnection, but returns data only
254
    }
255
}
256
```
257

            
258
---
259

            
260
## Connection Lifecycle
261

            
262
```
263
Device A Connects                Expiry Timer Started
264
    │                                    │
265
    ▼                                    ▼
266
┌─────────────────────────┐    ┌──────────────────┐
267
│ DeviceSettings created  │    │  120 seconds     │
268
│ connectedAt = now       │    │  (hardcoded TTL) │
269
│ connectedExpiryAt=now+2m│◄───┴──────────────────┘
270
└─────────────────────────┘
271
    │
272
    │ [CloudKit syncs]
273
    │
274
    ▼
275
Device B detects via notification:
276
  connectedByDeviceID ≠ my device → "Locked by Device A"
277

            
278
After 120 seconds:
279
  if (now > connectedExpiryAt) {
280
    // Lock expired
281
    // Device B can attempt new connection
282
  }
283
```
284

            
285
---
286

            
287
## Error Handling & Recovery
288

            
289
### Scenario: CloudKit Unavailable
290
- App detects no iCloud account → operations on viewContext proceed locally
291
- Changes queue locally
292
- When CloudKit available → sync resumes automatically
293

            
294
### Scenario: Duplicate Records (Pre-v3)
295
- `rebuildCanonicalStoreIfNeeded(version: 3)` runs at app launch
296
- Detects and merges duplicates in-place
297
- Marks as done in UserDefaults
298

            
299
### Scenario: Sync Conflicts
300
- Example: Device A and B both update `meterName` simultaneously
301
- `NSMergeByPropertyStoreTrumpMergePolicy` applies: **latest CloudKit wins**
302
- User may see their local change revert briefly (acceptable UX for rare case)
303

            
304
---
305

            
306
## Performance Considerations
307

            
308
| Operation | Thread | Frequency | Impact |
309
|-----------|--------|-----------|--------|
310
| `setConnection()` | Background (global QoS) | Per meter connection | Minimal (~5-10ms) |
311
| `reloadSettingsFromCloudStore()` | Main (UI updates) | On remote change + periodic | ~50-100ms for <10 meters |
312
| `rebuildCanonicalStoreIfNeeded()` | Main (view context) | Once per app version | ~200ms (one-time) |
313
| CloudKit sync (background) | System | Continuous | Battery: negligible |
314

            
315
---
316

            
317
## Testing Checklist
318

            
319
- [ ] Connect meter on Device A, verify "Connected on A" badge on Device B within 5s
320
- [ ] Disconnect on A, verify badge clears on B
321
- [ ] Rename meter on A, verify name updates on B
322
- [ ] Set TC66 temp unit on A, verify syncs to B
323
- [ ] Airplane mode on A → CloudKit queues changes → reconnect → syncs ✓
324
- [ ] Delete app data on A, reinstall → rebuilds from CloudKit ✓
325
- [ ] Simulated multi-device conflict (manual Core Data edit) → resolves via merge policy ✓