@mentra/crust 0.1.0-beta.0

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