@glassly/crust 0.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +41 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +33 -0
  6. package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
  7. package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
  11. package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
  13. package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
  14. package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
  15. package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
  16. package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/app.plugin.js +3 -0
  19. package/build/Crust.types.d.ts +153 -0
  20. package/build/Crust.types.d.ts.map +1 -0
  21. package/build/Crust.types.js +2 -0
  22. package/build/Crust.types.js.map +1 -0
  23. package/build/CrustModule.d.ts +186 -0
  24. package/build/CrustModule.d.ts.map +1 -0
  25. package/build/CrustModule.js +4 -0
  26. package/build/CrustModule.js.map +1 -0
  27. package/build/CrustModule.web.d.ts +39 -0
  28. package/build/CrustModule.web.d.ts.map +1 -0
  29. package/build/CrustModule.web.js +69 -0
  30. package/build/CrustModule.web.js.map +1 -0
  31. package/build/CrustView.d.ts +4 -0
  32. package/build/CrustView.d.ts.map +1 -0
  33. package/build/CrustView.js +8 -0
  34. package/build/CrustView.js.map +1 -0
  35. package/build/CrustView.web.d.ts +3 -0
  36. package/build/CrustView.web.d.ts.map +1 -0
  37. package/build/CrustView.web.js +5 -0
  38. package/build/CrustView.web.js.map +1 -0
  39. package/build/index.d.ts +4 -0
  40. package/build/index.d.ts.map +1 -0
  41. package/build/index.js +6 -0
  42. package/build/index.js.map +1 -0
  43. package/expo-module.config.json +9 -0
  44. package/ios/Crust.podspec +65 -0
  45. package/ios/CrustModule.swift +784 -0
  46. package/ios/CrustView.swift +38 -0
  47. package/ios/Resources/startup.js +804 -0
  48. package/ios/Source/JSCDispatcher.swift +226 -0
  49. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  50. package/ios/Source/JSCRuntime.swift +674 -0
  51. package/ios/Source/utils/ImageProcessor.swift +392 -0
  52. package/ios/Source/utils/SystemGestures.swift +53 -0
  53. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  54. package/ios/heading/HeadingManager.swift +74 -0
  55. package/ios/navigation/NavPayloads.swift +62 -0
  56. package/ios/navigation/NavigationManager.swift +751 -0
  57. package/package.json +69 -0
  58. package/plugin/build/index.d.ts +21 -0
  59. package/plugin/build/index.js +25 -0
  60. package/plugin/build/withAndroid.d.ts +2 -0
  61. package/plugin/build/withAndroid.js +133 -0
  62. package/src/Crust.types.ts +162 -0
  63. package/src/CrustModule.ts +194 -0
  64. package/src/CrustModule.web.ts +72 -0
  65. package/src/CrustView.tsx +10 -0
  66. package/src/CrustView.web.tsx +9 -0
  67. package/src/index.ts +5 -0
@@ -0,0 +1,784 @@
1
+ import AVKit
2
+ import CoreLocation
3
+ import ExpoModulesCore
4
+ import Photos
5
+
6
+ /// User-visible album in Apple Photos for glasses sync (matches dedicated-folder behavior on Android).
7
+ private enum SyncedMediaAlbum {
8
+ static let localizedTitle = "Glassly"
9
+ }
10
+
11
+ /// Serializes callback and timeout delivery for PhotoKit APIs that may call back more than once.
12
+ private final class CheckedContinuationGate<Value>: @unchecked Sendable {
13
+ private let lock = NSLock()
14
+ private var continuation: CheckedContinuation<Value, Never>?
15
+
16
+ init(_ continuation: CheckedContinuation<Value, Never>) {
17
+ self.continuation = continuation
18
+ }
19
+
20
+ func resume(returning value: Value) {
21
+ lock.lock()
22
+ guard let continuation else {
23
+ lock.unlock()
24
+ return
25
+ }
26
+ self.continuation = nil
27
+ lock.unlock()
28
+ continuation.resume(returning: value)
29
+ }
30
+ }
31
+
32
+ private final class PhotoLibrarySaveState: @unchecked Sendable {
33
+ private let lock = NSLock()
34
+ private var assetIdentifier: String?
35
+ private var creationFailed = false
36
+
37
+ func setAssetIdentifier(_ identifier: String) {
38
+ lock.lock()
39
+ assetIdentifier = identifier
40
+ lock.unlock()
41
+ }
42
+
43
+ func markCreationFailed() {
44
+ lock.lock()
45
+ creationFailed = true
46
+ lock.unlock()
47
+ }
48
+
49
+ func snapshot() -> (assetIdentifier: String?, creationFailed: Bool) {
50
+ lock.lock()
51
+ defer { lock.unlock() }
52
+ return (assetIdentifier, creationFailed)
53
+ }
54
+ }
55
+
56
+ private struct PhotoLibrarySaveResult {
57
+ let succeeded: Bool
58
+ let assetIdentifier: String?
59
+ let creationFailed: Bool
60
+ let errorMessage: String?
61
+ let timedOut: Bool
62
+ }
63
+
64
+ public class CrustModule: Module {
65
+ public func definition() -> ModuleDefinition {
66
+ Name("Crust")
67
+
68
+ Constant("PI") {
69
+ Double.pi
70
+ }
71
+
72
+ Events(
73
+ "onChange",
74
+ "phone_notification",
75
+ "phone_notification_dismissed",
76
+ "captions_tester_incident",
77
+ "onNavManeuver",
78
+ "onNavRerouting",
79
+ "onNavArrived",
80
+ "onNavError",
81
+ "onNavOffRoute",
82
+ "onNavLocation",
83
+ "onNavRoute",
84
+ "onHeading",
85
+ // GlasslyJS — fires whenever a per-miniapp JSContext calls
86
+ // __dispatch(iface, method, args). RN-side JSRouter
87
+ // subscribes to route by packageName.
88
+ "glasslyjs_message"
89
+ )
90
+
91
+ OnCreate {
92
+ // Wire the JSCRuntime's outbound event sink to the Expo
93
+ // event emitter. The JSCDispatcher fires `forwardToRn` for
94
+ // anything that isn't a built-in route (localStorage, fetch,
95
+ // crypto.getRandomBytes, __runtime.ready), and the runtime's
96
+ // exception/log/error handlers route through the same sink.
97
+ JSCRuntime.shared.onOutbound = { [weak self] message in
98
+ self?.sendEvent("glasslyjs_message", message.payload)
99
+ }
100
+ // Install the polyfill bridge once, lazily on first module
101
+ // creation. JSCPolyfillBridge.install is idempotent.
102
+ JSCPolyfillBridge.install(into: JSCRuntime.shared.dispatcherTable)
103
+ }
104
+
105
+ Function("hello") {
106
+ "Hello world! 👋"
107
+ }
108
+
109
+ AsyncFunction("setValueAsync") { (value: String) in
110
+ self.sendEvent("onChange", [
111
+ "value": value,
112
+ ])
113
+ }
114
+
115
+ AsyncFunction("requestNavigationPermission") { () -> [String: Any] in
116
+ await withCheckedContinuation { continuation in
117
+ // NavigationManager is @MainActor-isolated; hop onto the main
118
+ // actor before touching it from this nonisolated AsyncFunction.
119
+ Task { @MainActor in
120
+ NavigationManager.shared.requestPermission { accepted in
121
+ continuation.resume(returning: ["ok": true, "accepted": accepted])
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // Runtime Mapbox token override (user key from Settings > Keys).
128
+ // Non-empty wins over the Info.plist MBXAccessToken; "" restores it.
129
+ AsyncFunction("setMapboxAccessToken") { (token: String) -> [String: Any] in
130
+ await MainActor.run { NavigationManager.shared.setAccessTokenOverride(token) }
131
+ return ["ok": true]
132
+ }
133
+
134
+ AsyncFunction("startNavigation") { (lat: Double, lng: Double, options: [String: Any]?) -> [String: Any] in
135
+ let simulate = options?["simulate"] as? Bool ?? false
136
+ let speedMultiplier = options?["speedMultiplier"] as? Double ?? 1.0
137
+ let mode = options?["mode"] as? String ?? "driving"
138
+ // Opt-in: when > 0, the NavigationManager forces a reroute as
139
+ // soon as the user is this many meters past a pivot they
140
+ // didn't take. nil disables the check entirely.
141
+ let missedTurnRerouteMeters: Double? = {
142
+ if let d = options?["missedTurnRerouteMeters"] as? Double { return d > 0 ? d : nil }
143
+ if let i = options?["missedTurnRerouteMeters"] as? Int { return i > 0 ? Double(i) : nil }
144
+ return nil
145
+ }()
146
+
147
+ var stops: [(lat: Double, lng: Double)] = []
148
+ if let stopsArr = options?["stops"] as? [[String: Double]] {
149
+ stops = stopsArr.compactMap { s in
150
+ guard let slat = s["lat"], let slng = s["lng"] else { return nil }
151
+ return (lat: slat, lng: slng)
152
+ }
153
+ }
154
+ if stops.isEmpty { stops = [(lat: lat, lng: lng)] }
155
+
156
+ return await withCheckedContinuation { continuation in
157
+ // NavigationManager is @MainActor-isolated; hop onto the main actor
158
+ // before calling start() from this nonisolated AsyncFunction.
159
+ Task { @MainActor in
160
+ NavigationManager.shared.start(
161
+ stops: stops,
162
+ mode: mode,
163
+ simulate: simulate,
164
+ speedMultiplier: speedMultiplier,
165
+ missedTurnRerouteMeters: missedTurnRerouteMeters,
166
+ onEvent: { [weak self] payload in
167
+ guard let self else { return }
168
+ let kind = payload["kind"] as? String ?? ""
169
+ switch kind {
170
+ case "maneuver": self.sendEvent("onNavManeuver", payload)
171
+ case "rerouting": self.sendEvent("onNavRerouting", payload)
172
+ case "arrived": self.sendEvent("onNavArrived", payload)
173
+ case "off_route": self.sendEvent("onNavOffRoute", payload)
174
+ case "error": self.sendEvent("onNavError", payload)
175
+ default: break
176
+ }
177
+ },
178
+ onLocation: { [weak self] payload in
179
+ self?.sendEvent("onNavLocation", payload)
180
+ },
181
+ onRoute: { [weak self] payload in
182
+ self?.sendEvent("onNavRoute", payload)
183
+ }
184
+ ) { ok, error in
185
+ var result: [String: Any] = ["ok": ok]
186
+ if let error { result["error"] = error }
187
+ continuation.resume(returning: result)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ AsyncFunction("stopNavigation") { () -> [String: Any] in
194
+ await MainActor.run { NavigationManager.shared.stop() }
195
+ return ["ok": true]
196
+ }
197
+
198
+ AsyncFunction("simulateDeviation") { (offsetMeters: Double?) -> [String: Any] in
199
+ await MainActor.run { NavigationManager.shared.simulateDeviation(offsetMeters: offsetMeters ?? 50) }
200
+ return ["ok": true]
201
+ }
202
+
203
+ // iOS doesn't implement the dev toggles yet. Return an explicit
204
+ // error so the JS side can surface "not supported" instead of
205
+ // silently believing the call succeeded.
206
+ AsyncFunction("setWrongSidewalkOffset") { (_: Bool) -> [String: Any] in
207
+ return ["ok": false, "error": "Not implemented on iOS"]
208
+ }
209
+ AsyncFunction("setSkipCrossings") { (_: Bool) -> [String: Any] in
210
+ return ["ok": false, "error": "Not implemented on iOS"]
211
+ }
212
+
213
+ AsyncFunction("startHeading") { () -> [String: Any] in
214
+ HeadingManager.shared.start { [weak self] degrees in
215
+ self?.sendEvent("onHeading", ["degrees": degrees])
216
+ }
217
+ return ["ok": true]
218
+ }
219
+
220
+ AsyncFunction("stopHeading") { () -> [String: Any] in
221
+ HeadingManager.shared.stop()
222
+ return ["ok": true]
223
+ }
224
+
225
+ // Location:
226
+
227
+ AsyncFunction("showLocationServicesDialog") { () -> Bool in
228
+ return false
229
+ }
230
+
231
+ AsyncFunction("openLocationSettings") { () -> Bool in
232
+ return false
233
+ }
234
+
235
+ AsyncFunction("openAppSettings") { () -> Bool in
236
+ return false
237
+ }
238
+
239
+ AsyncFunction("openBluetoothSettings") { () -> Bool in
240
+ return false
241
+ }
242
+
243
+ // MARK: - Glassly Notification Commands
244
+
245
+ AsyncFunction("setNotificationConfig") { (_: Bool, _: [String]) in
246
+ // No-op on iOS
247
+ }
248
+
249
+ AsyncFunction("getInstalledApps") { () -> [[String: Any]] in
250
+ return []
251
+ }
252
+
253
+ AsyncFunction("getInstalledAppsForNotifications") { () -> [[String: Any]] in
254
+ return []
255
+ }
256
+
257
+ AsyncFunction("hasNotificationListenerPermission") { () -> Bool in
258
+ return false
259
+ }
260
+
261
+ AsyncFunction("refreshNotificationListener") { () -> Bool in
262
+ return false
263
+ }
264
+
265
+ AsyncFunction("openNotificationListenerSettings") { () -> Bool in
266
+ return false
267
+ }
268
+
269
+ // MARK: - GlasslyJS Runtime
270
+
271
+ /// Spawn a per-miniapp JS context. Re-spawn is allowed: a live
272
+ /// context with the same packageName is killed first.
273
+ /// Returns true if the polyfill + miniapp source evaluated without
274
+ /// throwing. On failure the context is torn down.
275
+ AsyncFunction("jsSpawn") { (packageName: String, polyfillBundle: String, miniappJs: String) -> Bool in
276
+ return JSCRuntime.shared.spawn(
277
+ packageName: packageName,
278
+ polyfillBundle: polyfillBundle,
279
+ miniappJs: miniappJs
280
+ )
281
+ }
282
+
283
+ /// Evaluate an arbitrary script inside the named context. Returns
284
+ /// the JS return value bridged to a JSON-friendly Swift type, or
285
+ /// nil if the context is dead / eval threw. Mostly for dev tooling
286
+ /// + tests; production code paths use jsDispatchToJs.
287
+ AsyncFunction("jsEvaluate") { (packageName: String, source: String) -> Any? in
288
+ return JSCRuntime.shared.evaluate(packageName: packageName, source: source)
289
+ }
290
+
291
+ /// Tear down a JS context. Cancels timers, drops refs, forces GC.
292
+ AsyncFunction("jsKill") { (packageName: String) -> Void in
293
+ JSCRuntime.shared.kill(packageName: packageName)
294
+ }
295
+
296
+ /// Push an event / response envelope into the named context's
297
+ /// globalThis.__deliver. Used by JSRouter for
298
+ /// glasses-status broadcasts and request/response correlation.
299
+ AsyncFunction("jsDispatchToJs") { (packageName: String, envelope: [String: Any]) -> Void in
300
+ JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
301
+ }
302
+
303
+ /// Set the installed manifest for a miniapp so the dispatcher's
304
+ /// permission gate can authorize sensitive `__dispatch` calls.
305
+ AsyncFunction("jsSetManifest") { (packageName: String, permissions: [String]) -> Void in
306
+ JSCRuntime.shared.dispatcherTable.setManifest(
307
+ packageName: packageName,
308
+ manifest: InstalledMiniappManifest(permissions: Set(permissions))
309
+ )
310
+ }
311
+
312
+ /// Diagnostic: list all live packageNames.
313
+ Function("jsAlivePackages") { () -> [String] in
314
+ return JSCRuntime.shared.alivePackages()
315
+ }
316
+
317
+ /// Diagnostic: force a JSC garbage collection cycle on the named
318
+ /// context. Used by memory-leak hunts + tests. Returns false when
319
+ /// the context is dead.
320
+ AsyncFunction("jsDebugForceGC") { (packageName: String) -> Bool in
321
+ return JSCRuntime.shared.debugForceGC(packageName: packageName)
322
+ }
323
+
324
+ /// Read the bundled GlasslyJS polyfill (startup.js) from the iOS
325
+ /// pod's resource bundle. The host calls this once on app boot,
326
+ /// caches the string, and passes it to every jsSpawn so
327
+ /// every JSContext starts with the same polyfill ABI.
328
+ Function("jsLoadPolyfillBundle") { () -> String in
329
+ return JSCRuntime.loadPolyfillBundle()
330
+ }
331
+
332
+ // MARK: - Build Environment
333
+
334
+ AsyncFunction("isBetaBuild") { () -> Bool in
335
+ #if targetEnvironment(simulator)
336
+ return false
337
+ #else
338
+ return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
339
+ #endif
340
+ }
341
+
342
+ // Configure which screen edges defer iOS system gestures
343
+ // (Control Center, Notification Center, Home indicator). Edge
344
+ // strings: "top", "bottom", "left", "right", "all". An empty
345
+ // array restores default behavior. iOS-only; Android is a no-op.
346
+ AsyncFunction("setDeferredSystemGestures") { (edges: [String]) -> Void in
347
+ var rect: UIRectEdge = []
348
+ for edge in edges {
349
+ switch edge.lowercased() {
350
+ case "top": rect.insert(.top)
351
+ case "bottom": rect.insert(.bottom)
352
+ case "left": rect.insert(.left)
353
+ case "right": rect.insert(.right)
354
+ case "all": rect = .all
355
+ default: break
356
+ }
357
+ }
358
+ SystemGestures.setDeferredEdges(rect)
359
+ }
360
+
361
+ Function("showAVRoutePicker") { (tintColor: String?) in
362
+ DispatchQueue.main.async {
363
+ let picker = AVRoutePickerView()
364
+ picker.prioritizesVideoDevices = false
365
+
366
+ if let colorString = tintColor {
367
+ picker.tintColor = UIColor(hexString: colorString)
368
+ } else {
369
+ picker.tintColor = .label
370
+ }
371
+
372
+ if let button = picker.subviews.first(where: { $0 is UIButton }) as? UIButton {
373
+ button.sendActions(for: .touchUpInside)
374
+ }
375
+ }
376
+ }
377
+
378
+ View(CrustView.self) {
379
+ Prop("url") { (view: CrustView, url: URL) in
380
+ if view.webView.url != url {
381
+ view.webView.load(URLRequest(url: url))
382
+ }
383
+ }
384
+
385
+ Events("onLoad")
386
+ }
387
+
388
+ // MARK: - Image Processing Commands
389
+
390
+ AsyncFunction("processGalleryImage") {
391
+ (inputPath: String, outputPath: String, options: [String: Any]) -> [String: Any] in
392
+ let lensCorrection = options["lensCorrection"] as? Bool ?? true
393
+ let colorCorrection = options["colorCorrection"] as? Bool ?? true
394
+
395
+ guard FileManager.default.fileExists(atPath: inputPath) else {
396
+ return ["success": false, "error": "Input file does not exist"]
397
+ }
398
+
399
+ let processingTimeMs = ImageProcessor.process(
400
+ inputPath: inputPath,
401
+ outputPath: outputPath,
402
+ lensCorrection: lensCorrection,
403
+ colorCorrection: colorCorrection
404
+ )
405
+
406
+ if processingTimeMs >= 0 {
407
+ return [
408
+ "success": true,
409
+ "outputPath": outputPath,
410
+ "processingTimeMs": processingTimeMs,
411
+ ]
412
+ } else {
413
+ return ["success": false, "error": "Processing failed"]
414
+ }
415
+ }
416
+
417
+ // MARK: - HDR Merge Commands
418
+
419
+ AsyncFunction("mergeHdrBrackets") {
420
+ (underPath: String, normalPath: String, overPath: String, outputPath: String)
421
+ -> [String: Any] in
422
+ let processingTimeMs = ImageProcessor.mergeHdr(
423
+ underPath: underPath,
424
+ normalPath: normalPath,
425
+ overPath: overPath,
426
+ outputPath: outputPath
427
+ )
428
+ if processingTimeMs >= 0 {
429
+ return [
430
+ "success": true,
431
+ "outputPath": outputPath,
432
+ "processingTimeMs": processingTimeMs,
433
+ ]
434
+ } else {
435
+ return ["success": false, "error": "HDR merge failed"]
436
+ }
437
+ }
438
+
439
+ // MARK: - Video Stabilization Commands
440
+
441
+ AsyncFunction("stabilizeVideo") {
442
+ (inputPath: String, imuPath: String, outputPath: String) -> [String: Any] in
443
+ guard FileManager.default.fileExists(atPath: inputPath) else {
444
+ return ["success": false, "error": "Input video does not exist"]
445
+ }
446
+ guard FileManager.default.fileExists(atPath: imuPath) else {
447
+ return ["success": false, "error": "IMU sidecar does not exist"]
448
+ }
449
+
450
+ let processingTimeMs = VideoStabilizer.stabilize(
451
+ inputPath: inputPath,
452
+ imuPath: imuPath,
453
+ outputPath: outputPath
454
+ )
455
+
456
+ if processingTimeMs >= 0 {
457
+ return [
458
+ "success": true,
459
+ "outputPath": outputPath,
460
+ "processingTimeMs": processingTimeMs,
461
+ ]
462
+ } else {
463
+ return ["success": false, "error": "Stabilization failed"]
464
+ }
465
+ }
466
+
467
+ // MARK: - Media Library Commands
468
+
469
+ AsyncFunction("saveToGalleryWithDate") {
470
+ (
471
+ filePath: String,
472
+ captureTimeMillis: Int64?,
473
+ displayName: String?
474
+ ) -> [String: Any] in
475
+ let fileURL = URL(fileURLWithPath: filePath)
476
+
477
+ guard FileManager.default.fileExists(atPath: filePath) else {
478
+ return ["success": false, "error": "File does not exist"]
479
+ }
480
+
481
+ let pathExtension = fileURL.pathExtension.lowercased()
482
+ let isVideo = ["mp4", "mov", "avi", "m4v"].contains(pathExtension)
483
+ let assetFileName = displayName?.isEmpty == false
484
+ ? displayName!
485
+ : fileURL.lastPathComponent
486
+ let captureDate = captureTimeMillis.map {
487
+ Date(timeIntervalSince1970: TimeInterval($0) / 1000.0)
488
+ }
489
+ let candidateFileNames = [assetFileName, fileURL.lastPathComponent]
490
+ .filter { !$0.isEmpty }
491
+ let stableFileName = assetFileName.hasPrefix("IMG_") || assetFileName.hasPrefix("VID_")
492
+ ? assetFileName
493
+ : nil
494
+ let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath)
495
+ let expectedFileSize = (fileAttributes?[.size] as? NSNumber)?.int64Value
496
+ let authorizationStatus: PHAuthorizationStatus
497
+ if #available(iOS 14, *) {
498
+ authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
499
+ } else {
500
+ authorizationStatus = PHPhotoLibrary.authorizationStatus()
501
+ }
502
+ let hasLimitedAccess: Bool
503
+ if #available(iOS 14, *) {
504
+ hasLimitedAccess = authorizationStatus == .limited
505
+ } else {
506
+ hasLimitedAccess = false
507
+ }
508
+
509
+ // The JS ledger normally supplies the previous PhotoKit receipt. If the app was
510
+ // terminated after Photos committed but before that receipt was persisted, reconcile
511
+ // by our stable capture filename/date before creating another asset.
512
+ if let existingIdentifier = await self.findExistingGalleryAssetIdentifier(
513
+ fileNames: candidateFileNames,
514
+ stableFileName: stableFileName,
515
+ expectedFileSize: expectedFileSize,
516
+ captureDate: captureDate,
517
+ isVideo: isVideo
518
+ ) {
519
+ NSLog("CrustModule: Reusing existing gallery asset")
520
+ return [
521
+ "success": true,
522
+ "identifier": existingIdentifier,
523
+ "existing": true,
524
+ ]
525
+ }
526
+
527
+ let saveResult = await self.createGalleryAsset(
528
+ fileURL: fileURL,
529
+ assetFileName: assetFileName,
530
+ captureDate: captureDate,
531
+ isVideo: isVideo,
532
+ hasLimitedAccess: hasLimitedAccess
533
+ )
534
+ if saveResult.timedOut {
535
+ return [
536
+ "success": false,
537
+ "error": "Photo library save timed out; retry will reconcile the result",
538
+ ]
539
+ }
540
+ if saveResult.creationFailed {
541
+ return ["success": false, "error": "Failed to create PhotoKit asset placeholder"]
542
+ }
543
+ if let errorMessage = saveResult.errorMessage {
544
+ NSLog("CrustModule: Error saving to gallery: \(errorMessage)")
545
+ return ["success": false, "error": errorMessage]
546
+ }
547
+ guard saveResult.succeeded else {
548
+ return ["success": false, "error": "Photo library did not commit the asset"]
549
+ }
550
+
551
+ NSLog("CrustModule: Successfully saved to gallery with proper creation date")
552
+ return ["success": true, "identifier": saveResult.assetIdentifier ?? ""]
553
+ }
554
+ }
555
+
556
+ private func createGalleryAsset(
557
+ fileURL: URL,
558
+ assetFileName: String,
559
+ captureDate: Date?,
560
+ isVideo: Bool,
561
+ hasLimitedAccess: Bool
562
+ ) async -> PhotoLibrarySaveResult {
563
+ await withCheckedContinuation { continuation in
564
+ let gate = CheckedContinuationGate(continuation)
565
+ let state = PhotoLibrarySaveState()
566
+
567
+ PHPhotoLibrary.shared().performChanges {
568
+ let creationRequest = PHAssetCreationRequest.forAsset()
569
+ let resourceOptions = PHAssetResourceCreationOptions()
570
+ resourceOptions.originalFilename = assetFileName
571
+ creationRequest.addResource(
572
+ with: isVideo ? .video : .photo,
573
+ fileURL: fileURL,
574
+ options: resourceOptions
575
+ )
576
+
577
+ if let captureDate {
578
+ creationRequest.creationDate = captureDate
579
+ NSLog("CrustModule: Setting creation date to: \(captureDate)")
580
+ }
581
+
582
+ guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
583
+ NSLog("CrustModule: Missing placeholder for created asset")
584
+ state.markCreationFailed()
585
+ return
586
+ }
587
+ state.setAssetIdentifier(assetPlaceholder.localIdentifier)
588
+
589
+ // Limited-library access permits creating an asset, but does not reliably
590
+ // permit enumerating or mutating arbitrary user albums. Save to the camera
591
+ // roll and skip Glassly album mutation in that mode.
592
+ guard !hasLimitedAccess else { return }
593
+
594
+ let albumFetch = PHFetchOptions()
595
+ albumFetch.predicate = NSPredicate(
596
+ format: "localizedTitle == %@", SyncedMediaAlbum.localizedTitle
597
+ )
598
+ albumFetch.fetchLimit = 1
599
+ let existingAlbums = PHAssetCollection.fetchAssetCollections(
600
+ with: .album,
601
+ subtype: .albumRegular,
602
+ options: albumFetch
603
+ )
604
+
605
+ if let album = existingAlbums.firstObject,
606
+ let albumChange = PHAssetCollectionChangeRequest(for: album)
607
+ {
608
+ albumChange.addAssets([assetPlaceholder] as NSArray)
609
+ } else if existingAlbums.firstObject == nil {
610
+ let newAlbumChange = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(
611
+ withTitle: SyncedMediaAlbum.localizedTitle
612
+ )
613
+ newAlbumChange.addAssets([assetPlaceholder] as NSArray)
614
+ } else {
615
+ NSLog(
616
+ "CrustModule: Glassly album exists but is not writable; asset saved to library only"
617
+ )
618
+ }
619
+ } completionHandler: { succeeded, error in
620
+ let snapshot = state.snapshot()
621
+ gate.resume(returning: PhotoLibrarySaveResult(
622
+ succeeded: succeeded,
623
+ assetIdentifier: snapshot.assetIdentifier,
624
+ creationFailed: snapshot.creationFailed,
625
+ errorMessage: error?.localizedDescription,
626
+ timedOut: false
627
+ ))
628
+ }
629
+
630
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 120) {
631
+ let snapshot = state.snapshot()
632
+ gate.resume(returning: PhotoLibrarySaveResult(
633
+ succeeded: false,
634
+ assetIdentifier: snapshot.assetIdentifier,
635
+ creationFailed: snapshot.creationFailed,
636
+ errorMessage: nil,
637
+ timedOut: true
638
+ ))
639
+ }
640
+ }
641
+ }
642
+
643
+ private func findExistingGalleryAssetIdentifier(
644
+ fileNames: [String],
645
+ stableFileName: String?,
646
+ expectedFileSize: Int64?,
647
+ captureDate: Date?,
648
+ isVideo: Bool
649
+ ) async -> String? {
650
+ guard !fileNames.isEmpty else { return nil }
651
+ let options = PHFetchOptions()
652
+ let mediaType = isVideo ? PHAssetMediaType.video : PHAssetMediaType.image
653
+ if let captureDate {
654
+ options.predicate = NSPredicate(
655
+ format: "mediaType == %d AND creationDate >= %@ AND creationDate <= %@",
656
+ mediaType.rawValue,
657
+ captureDate.addingTimeInterval(-1) as NSDate,
658
+ captureDate.addingTimeInterval(1) as NSDate
659
+ )
660
+ } else {
661
+ options.predicate = NSPredicate(format: "mediaType == %d", mediaType.rawValue)
662
+ options.fetchLimit = 200
663
+ }
664
+ options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
665
+
666
+ let assets = PHAsset.fetchAssets(with: options)
667
+ var stableCandidates: [PHAsset] = []
668
+ var legacyCandidates: [PHAsset] = []
669
+ assets.enumerateObjects { asset, _, _ in
670
+ let resources = PHAssetResource.assetResources(for: asset)
671
+ let stableMatch = stableFileName.map { stableName in
672
+ resources.contains { $0.originalFilename == stableName }
673
+ } ?? false
674
+ if stableMatch {
675
+ stableCandidates.append(asset)
676
+ return
677
+ }
678
+ let legacyNameMatch = resources.contains {
679
+ guard fileNames.contains($0.originalFilename), $0.originalFilename != stableFileName else {
680
+ return false
681
+ }
682
+ return true
683
+ }
684
+ if legacyNameMatch { legacyCandidates.append(asset) }
685
+ }
686
+ guard let expectedFileSize else { return nil }
687
+ // PhotoKit's original-resource lookups are asynchronous. Resolve candidates after
688
+ // enumeration so the fetch callback never blocks the Photos framework. Stable names
689
+ // may repeat across a restored/older library; require the generation's exact byte size
690
+ // and reuse the newest matching completed asset.
691
+ for asset in stableCandidates {
692
+ if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
693
+ return asset.localIdentifier
694
+ }
695
+ }
696
+
697
+ var legacyIdentifiers: [String] = []
698
+ for asset in legacyCandidates {
699
+ if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
700
+ legacyIdentifiers.append(asset.localIdentifier)
701
+ }
702
+ }
703
+ // Legacy base names remain safe only when name + capture time + media type + exact
704
+ // original byte size identify one asset.
705
+ return legacyIdentifiers.count == 1 ? legacyIdentifiers[0] : nil
706
+ }
707
+
708
+ /// PhotoKit does not expose resource byte size on the deployment targets we support.
709
+ /// Inspect a video's local original URL or an image's original bytes asynchronously.
710
+ /// Network access stays disabled: if the original is only in iCloud, reconciliation fails
711
+ /// closed and the caller creates a fresh local-library asset instead of risking data loss.
712
+ private func assetOriginalFile(
713
+ _ asset: PHAsset,
714
+ matchesByteCount expectedByteCount: Int64
715
+ ) async -> Bool {
716
+ let manager = PHImageManager.default()
717
+ if asset.mediaType == .video {
718
+ let options = PHVideoRequestOptions()
719
+ options.isNetworkAccessAllowed = false
720
+ options.version = .original
721
+ return await withCheckedContinuation { continuation in
722
+ let gate = CheckedContinuationGate(continuation)
723
+ let requestID = manager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in
724
+ guard let originalURL = (avAsset as? AVURLAsset)?.url,
725
+ let attributes = try? FileManager.default.attributesOfItem(atPath: originalURL.path),
726
+ let byteCount = attributes[.size] as? NSNumber else {
727
+ gate.resume(returning: false)
728
+ return
729
+ }
730
+ gate.resume(returning: byteCount.int64Value == expectedByteCount)
731
+ }
732
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
733
+ manager.cancelImageRequest(requestID)
734
+ gate.resume(returning: false)
735
+ }
736
+ }
737
+ }
738
+
739
+ let options = PHImageRequestOptions()
740
+ options.isNetworkAccessAllowed = false
741
+ options.version = .original
742
+ options.deliveryMode = .highQualityFormat
743
+ return await withCheckedContinuation { continuation in
744
+ let gate = CheckedContinuationGate(continuation)
745
+ let requestID = manager.requestImageDataAndOrientation(for: asset, options: options) { data, _, _, info in
746
+ if info?[PHImageResultIsDegradedKey] as? Bool == true { return }
747
+ gate.resume(returning: data.map { Int64($0.count) == expectedByteCount } ?? false)
748
+ }
749
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
750
+ manager.cancelImageRequest(requestID)
751
+ gate.resume(returning: false)
752
+ }
753
+ }
754
+ }
755
+ }
756
+
757
+ extension UIColor {
758
+ convenience init?(hexString: String) {
759
+ var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
760
+ hex = hex.replacingOccurrences(of: "#", with: "")
761
+
762
+ var rgb: UInt64 = 0
763
+ guard Scanner(string: hex).scanHexInt64(&rgb) else { return nil }
764
+
765
+ let length = hex.count
766
+ let r, g, b, a: CGFloat
767
+
768
+ if length == 6 {
769
+ r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
770
+ g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
771
+ b = CGFloat(rgb & 0x0000FF) / 255.0
772
+ a = 1.0
773
+ } else if length == 8 {
774
+ r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
775
+ g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
776
+ b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
777
+ a = CGFloat(rgb & 0x0000_00FF) / 255.0
778
+ } else {
779
+ return nil
780
+ }
781
+
782
+ self.init(red: r, green: g, blue: b, alpha: a)
783
+ }
784
+ }