@mentra/crust 0.1.0-dev.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.
- package/README.md +35 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +175 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +26 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +54 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +544 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +186 -0
- package/src/CrustModule.web.ts +57 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,544 @@
|
|
|
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
|
+
public class CrustModule: Module {
|
|
12
|
+
public func definition() -> ModuleDefinition {
|
|
13
|
+
Name("Crust")
|
|
14
|
+
|
|
15
|
+
Constant("PI") {
|
|
16
|
+
Double.pi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Events(
|
|
20
|
+
"onChange",
|
|
21
|
+
"phone_notification",
|
|
22
|
+
"phone_notification_dismissed",
|
|
23
|
+
"captions_tester_incident",
|
|
24
|
+
"onNavManeuver",
|
|
25
|
+
"onNavRerouting",
|
|
26
|
+
"onNavArrived",
|
|
27
|
+
"onNavError",
|
|
28
|
+
"onNavOffRoute",
|
|
29
|
+
"onNavLocation",
|
|
30
|
+
"onNavRoute",
|
|
31
|
+
"onHeading",
|
|
32
|
+
// MentraJS — fires whenever a per-miniapp JSContext calls
|
|
33
|
+
// __dispatch(iface, method, args). RN-side MentraJSRouter
|
|
34
|
+
// subscribes to route by packageName.
|
|
35
|
+
"mentrajs_message"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
OnCreate {
|
|
39
|
+
// Wire the JSCRuntime's outbound event sink to the Expo
|
|
40
|
+
// event emitter. The JSCDispatcher fires `forwardToRn` for
|
|
41
|
+
// anything that isn't a built-in route (localStorage, fetch,
|
|
42
|
+
// crypto.getRandomBytes, __runtime.ready), and the runtime's
|
|
43
|
+
// exception/log/error handlers route through the same sink.
|
|
44
|
+
JSCRuntime.shared.onOutbound = { [weak self] message in
|
|
45
|
+
self?.sendEvent("mentrajs_message", message.payload)
|
|
46
|
+
}
|
|
47
|
+
// Install the polyfill bridge once, lazily on first module
|
|
48
|
+
// creation. JSCPolyfillBridge.install is idempotent.
|
|
49
|
+
JSCPolyfillBridge.install(into: JSCRuntime.shared.dispatcherTable)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Function("hello") {
|
|
53
|
+
"Hello world! 👋"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
AsyncFunction("setValueAsync") { (value: String) in
|
|
57
|
+
self.sendEvent("onChange", [
|
|
58
|
+
"value": value,
|
|
59
|
+
])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
AsyncFunction("requestNavigationPermission") { () -> [String: Any] in
|
|
63
|
+
await withCheckedContinuation { continuation in
|
|
64
|
+
// NavigationManager is @MainActor-isolated; hop onto the main
|
|
65
|
+
// actor before touching it from this nonisolated AsyncFunction.
|
|
66
|
+
Task { @MainActor in
|
|
67
|
+
NavigationManager.shared.requestPermission { accepted in
|
|
68
|
+
continuation.resume(returning: ["ok": true, "accepted": accepted])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Mapbox has no Terms & Conditions dialog (that was Google-specific),
|
|
75
|
+
// so there's nothing to reset. No-op for parity with Android, whose
|
|
76
|
+
// resetTermsAccepted is also a Mapbox no-op. Returns ok:true since the
|
|
77
|
+
// "reset" is trivially satisfied (no accepted-terms state exists).
|
|
78
|
+
AsyncFunction("resetNavigationPermission") { () -> [String: Any] in
|
|
79
|
+
return ["ok": true]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
AsyncFunction("startNavigation") { (lat: Double, lng: Double, options: [String: Any]?) -> [String: Any] in
|
|
83
|
+
let simulate = options?["simulate"] as? Bool ?? false
|
|
84
|
+
let speedMultiplier = options?["speedMultiplier"] as? Double ?? 1.0
|
|
85
|
+
let mode = options?["mode"] as? String ?? "driving"
|
|
86
|
+
// Opt-in: when > 0, the NavigationManager forces a reroute as
|
|
87
|
+
// soon as the user is this many meters past a pivot they
|
|
88
|
+
// didn't take. nil disables the check entirely.
|
|
89
|
+
let missedTurnRerouteMeters: Double? = {
|
|
90
|
+
if let d = options?["missedTurnRerouteMeters"] as? Double { return d > 0 ? d : nil }
|
|
91
|
+
if let i = options?["missedTurnRerouteMeters"] as? Int { return i > 0 ? Double(i) : nil }
|
|
92
|
+
return nil
|
|
93
|
+
}()
|
|
94
|
+
|
|
95
|
+
var stops: [(lat: Double, lng: Double)] = []
|
|
96
|
+
if let stopsArr = options?["stops"] as? [[String: Double]] {
|
|
97
|
+
stops = stopsArr.compactMap { s in
|
|
98
|
+
guard let slat = s["lat"], let slng = s["lng"] else { return nil }
|
|
99
|
+
return (lat: slat, lng: slng)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if stops.isEmpty { stops = [(lat: lat, lng: lng)] }
|
|
103
|
+
|
|
104
|
+
return await withCheckedContinuation { continuation in
|
|
105
|
+
// NavigationManager is @MainActor-isolated; hop onto the main actor
|
|
106
|
+
// before calling start() from this nonisolated AsyncFunction.
|
|
107
|
+
Task { @MainActor in
|
|
108
|
+
NavigationManager.shared.start(
|
|
109
|
+
stops: stops,
|
|
110
|
+
mode: mode,
|
|
111
|
+
simulate: simulate,
|
|
112
|
+
speedMultiplier: speedMultiplier,
|
|
113
|
+
missedTurnRerouteMeters: missedTurnRerouteMeters,
|
|
114
|
+
onEvent: { [weak self] payload in
|
|
115
|
+
guard let self else { return }
|
|
116
|
+
let kind = payload["kind"] as? String ?? ""
|
|
117
|
+
switch kind {
|
|
118
|
+
case "maneuver": self.sendEvent("onNavManeuver", payload)
|
|
119
|
+
case "rerouting": self.sendEvent("onNavRerouting", payload)
|
|
120
|
+
case "arrived": self.sendEvent("onNavArrived", payload)
|
|
121
|
+
case "off_route": self.sendEvent("onNavOffRoute", payload)
|
|
122
|
+
case "error": self.sendEvent("onNavError", payload)
|
|
123
|
+
default: break
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
onLocation: { [weak self] payload in
|
|
127
|
+
self?.sendEvent("onNavLocation", payload)
|
|
128
|
+
},
|
|
129
|
+
onRoute: { [weak self] payload in
|
|
130
|
+
self?.sendEvent("onNavRoute", payload)
|
|
131
|
+
}
|
|
132
|
+
) { ok, error in
|
|
133
|
+
var result: [String: Any] = ["ok": ok]
|
|
134
|
+
if let error { result["error"] = error }
|
|
135
|
+
continuation.resume(returning: result)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
AsyncFunction("stopNavigation") { () -> [String: Any] in
|
|
142
|
+
await MainActor.run { NavigationManager.shared.stop() }
|
|
143
|
+
return ["ok": true]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
AsyncFunction("simulateDeviation") { (offsetMeters: Double?) -> [String: Any] in
|
|
147
|
+
await MainActor.run { NavigationManager.shared.simulateDeviation(offsetMeters: offsetMeters ?? 50) }
|
|
148
|
+
return ["ok": true]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// iOS doesn't implement the dev toggles yet. Return an explicit
|
|
152
|
+
// error so the JS side can surface "not supported" instead of
|
|
153
|
+
// silently believing the call succeeded.
|
|
154
|
+
AsyncFunction("setWrongSidewalkOffset") { (_: Bool) -> [String: Any] in
|
|
155
|
+
return ["ok": false, "error": "Not implemented on iOS"]
|
|
156
|
+
}
|
|
157
|
+
AsyncFunction("setSkipCrossings") { (_: Bool) -> [String: Any] in
|
|
158
|
+
return ["ok": false, "error": "Not implemented on iOS"]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
AsyncFunction("startHeading") { () -> [String: Any] in
|
|
162
|
+
HeadingManager.shared.start { [weak self] degrees in
|
|
163
|
+
self?.sendEvent("onHeading", ["degrees": degrees])
|
|
164
|
+
}
|
|
165
|
+
return ["ok": true]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
AsyncFunction("stopHeading") { () -> [String: Any] in
|
|
169
|
+
HeadingManager.shared.stop()
|
|
170
|
+
return ["ok": true]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Location:
|
|
174
|
+
|
|
175
|
+
AsyncFunction("showLocationServicesDialog") { () -> Bool in
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
AsyncFunction("openLocationSettings") { () -> Bool in
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
AsyncFunction("openAppSettings") { () -> Bool in
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
AsyncFunction("openBluetoothSettings") { () -> Bool in
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// MARK: - MentraOS Notification Commands
|
|
192
|
+
|
|
193
|
+
AsyncFunction("setNotificationConfig") { (_: Bool, _: [String]) in
|
|
194
|
+
// No-op on iOS
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
AsyncFunction("getInstalledApps") { () -> [[String: Any]] in
|
|
198
|
+
return []
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
AsyncFunction("getInstalledAppsForNotifications") { () -> [[String: Any]] in
|
|
202
|
+
return []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
AsyncFunction("hasNotificationListenerPermission") { () -> Bool in
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
AsyncFunction("openNotificationListenerSettings") { () -> Bool in
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// MARK: - MentraJS Runtime
|
|
214
|
+
|
|
215
|
+
/// Spawn a per-miniapp JS context. Re-spawn is allowed: a live
|
|
216
|
+
/// context with the same packageName is killed first.
|
|
217
|
+
/// Returns true if the polyfill + miniapp source evaluated without
|
|
218
|
+
/// throwing. On failure the context is torn down.
|
|
219
|
+
AsyncFunction("mentraJsSpawn") { (packageName: String, polyfillBundle: String, miniappJs: String) -> Bool in
|
|
220
|
+
return JSCRuntime.shared.spawn(
|
|
221
|
+
packageName: packageName,
|
|
222
|
+
polyfillBundle: polyfillBundle,
|
|
223
|
+
miniappJs: miniappJs
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Evaluate an arbitrary script inside the named context. Returns
|
|
228
|
+
/// the JS return value bridged to a JSON-friendly Swift type, or
|
|
229
|
+
/// nil if the context is dead / eval threw. Mostly for dev tooling
|
|
230
|
+
/// + tests; production code paths use mentraJsDispatchToJs.
|
|
231
|
+
AsyncFunction("mentraJsEvaluate") { (packageName: String, source: String) -> Any? in
|
|
232
|
+
return JSCRuntime.shared.evaluate(packageName: packageName, source: source)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Tear down a JS context. Cancels timers, drops refs, forces GC.
|
|
236
|
+
AsyncFunction("mentraJsKill") { (packageName: String) -> Void in
|
|
237
|
+
JSCRuntime.shared.kill(packageName: packageName)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Push an event / response envelope into the named context's
|
|
241
|
+
/// globalThis.__deliver. Used by MentraJSRouter for
|
|
242
|
+
/// glasses-status broadcasts and request/response correlation.
|
|
243
|
+
AsyncFunction("mentraJsDispatchToJs") { (packageName: String, envelope: [String: Any]) -> Void in
|
|
244
|
+
JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Set the installed manifest for a miniapp so the dispatcher's
|
|
248
|
+
/// permission gate can authorize sensitive `__dispatch` calls.
|
|
249
|
+
AsyncFunction("mentraJsSetManifest") { (packageName: String, permissions: [String]) -> Void in
|
|
250
|
+
JSCRuntime.shared.dispatcherTable.setManifest(
|
|
251
|
+
packageName: packageName,
|
|
252
|
+
manifest: InstalledMiniappManifest(permissions: Set(permissions))
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Diagnostic: list all live packageNames.
|
|
257
|
+
Function("mentraJsAlivePackages") { () -> [String] in
|
|
258
|
+
return JSCRuntime.shared.alivePackages()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// Diagnostic: force a JSC garbage collection cycle on the named
|
|
262
|
+
/// context. Used by memory-leak hunts + tests. Returns false when
|
|
263
|
+
/// the context is dead.
|
|
264
|
+
AsyncFunction("mentraJsDebugForceGC") { (packageName: String) -> Bool in
|
|
265
|
+
return JSCRuntime.shared.debugForceGC(packageName: packageName)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Read the bundled MentraJS polyfill (startup.js) from the iOS
|
|
269
|
+
/// pod's resource bundle. The host calls this once on app boot,
|
|
270
|
+
/// caches the string, and passes it to every mentraJsSpawn so
|
|
271
|
+
/// every JSContext starts with the same polyfill ABI.
|
|
272
|
+
Function("mentraJsLoadPolyfillBundle") { () -> String in
|
|
273
|
+
return JSCRuntime.loadPolyfillBundle()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// MARK: - Build Environment
|
|
277
|
+
|
|
278
|
+
AsyncFunction("isBetaBuild") { () -> Bool in
|
|
279
|
+
#if targetEnvironment(simulator)
|
|
280
|
+
return false
|
|
281
|
+
#else
|
|
282
|
+
return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
|
283
|
+
#endif
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Configure which screen edges defer iOS system gestures
|
|
287
|
+
// (Control Center, Notification Center, Home indicator). Edge
|
|
288
|
+
// strings: "top", "bottom", "left", "right", "all". An empty
|
|
289
|
+
// array restores default behavior. iOS-only; Android is a no-op.
|
|
290
|
+
AsyncFunction("setDeferredSystemGestures") { (edges: [String]) -> Void in
|
|
291
|
+
var rect: UIRectEdge = []
|
|
292
|
+
for edge in edges {
|
|
293
|
+
switch edge.lowercased() {
|
|
294
|
+
case "top": rect.insert(.top)
|
|
295
|
+
case "bottom": rect.insert(.bottom)
|
|
296
|
+
case "left": rect.insert(.left)
|
|
297
|
+
case "right": rect.insert(.right)
|
|
298
|
+
case "all": rect = .all
|
|
299
|
+
default: break
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
SystemGestures.setDeferredEdges(rect)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Function("showAVRoutePicker") { (tintColor: String?) in
|
|
306
|
+
DispatchQueue.main.async {
|
|
307
|
+
let picker = AVRoutePickerView()
|
|
308
|
+
picker.prioritizesVideoDevices = false
|
|
309
|
+
|
|
310
|
+
if let colorString = tintColor {
|
|
311
|
+
picker.tintColor = UIColor(hexString: colorString)
|
|
312
|
+
} else {
|
|
313
|
+
picker.tintColor = .label
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if let button = picker.subviews.first(where: { $0 is UIButton }) as? UIButton {
|
|
317
|
+
button.sendActions(for: .touchUpInside)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
View(CrustView.self) {
|
|
323
|
+
Prop("url") { (view: CrustView, url: URL) in
|
|
324
|
+
if view.webView.url != url {
|
|
325
|
+
view.webView.load(URLRequest(url: url))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
Events("onLoad")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// MARK: - Image Processing Commands
|
|
333
|
+
|
|
334
|
+
AsyncFunction("processGalleryImage") {
|
|
335
|
+
(inputPath: String, outputPath: String, options: [String: Any]) -> [String: Any] in
|
|
336
|
+
let lensCorrection = options["lensCorrection"] as? Bool ?? true
|
|
337
|
+
let colorCorrection = options["colorCorrection"] as? Bool ?? true
|
|
338
|
+
|
|
339
|
+
guard FileManager.default.fileExists(atPath: inputPath) else {
|
|
340
|
+
return ["success": false, "error": "Input file does not exist"]
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let processingTimeMs = ImageProcessor.process(
|
|
344
|
+
inputPath: inputPath,
|
|
345
|
+
outputPath: outputPath,
|
|
346
|
+
lensCorrection: lensCorrection,
|
|
347
|
+
colorCorrection: colorCorrection
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if processingTimeMs >= 0 {
|
|
351
|
+
return [
|
|
352
|
+
"success": true,
|
|
353
|
+
"outputPath": outputPath,
|
|
354
|
+
"processingTimeMs": processingTimeMs,
|
|
355
|
+
]
|
|
356
|
+
} else {
|
|
357
|
+
return ["success": false, "error": "Processing failed"]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// MARK: - HDR Merge Commands
|
|
362
|
+
|
|
363
|
+
AsyncFunction("mergeHdrBrackets") {
|
|
364
|
+
(underPath: String, normalPath: String, overPath: String, outputPath: String)
|
|
365
|
+
-> [String: Any] in
|
|
366
|
+
let processingTimeMs = ImageProcessor.mergeHdr(
|
|
367
|
+
underPath: underPath,
|
|
368
|
+
normalPath: normalPath,
|
|
369
|
+
overPath: overPath,
|
|
370
|
+
outputPath: outputPath
|
|
371
|
+
)
|
|
372
|
+
if processingTimeMs >= 0 {
|
|
373
|
+
return [
|
|
374
|
+
"success": true,
|
|
375
|
+
"outputPath": outputPath,
|
|
376
|
+
"processingTimeMs": processingTimeMs,
|
|
377
|
+
]
|
|
378
|
+
} else {
|
|
379
|
+
return ["success": false, "error": "HDR merge failed"]
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// MARK: - Video Stabilization Commands
|
|
384
|
+
|
|
385
|
+
AsyncFunction("stabilizeVideo") {
|
|
386
|
+
(inputPath: String, imuPath: String, outputPath: String) -> [String: Any] in
|
|
387
|
+
guard FileManager.default.fileExists(atPath: inputPath) else {
|
|
388
|
+
return ["success": false, "error": "Input video does not exist"]
|
|
389
|
+
}
|
|
390
|
+
guard FileManager.default.fileExists(atPath: imuPath) else {
|
|
391
|
+
return ["success": false, "error": "IMU sidecar does not exist"]
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let processingTimeMs = VideoStabilizer.stabilize(
|
|
395
|
+
inputPath: inputPath,
|
|
396
|
+
imuPath: imuPath,
|
|
397
|
+
outputPath: outputPath
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if processingTimeMs >= 0 {
|
|
401
|
+
return [
|
|
402
|
+
"success": true,
|
|
403
|
+
"outputPath": outputPath,
|
|
404
|
+
"processingTimeMs": processingTimeMs,
|
|
405
|
+
]
|
|
406
|
+
} else {
|
|
407
|
+
return ["success": false, "error": "Stabilization failed"]
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// MARK: - Media Library Commands
|
|
412
|
+
|
|
413
|
+
AsyncFunction("saveToGalleryWithDate") {
|
|
414
|
+
(filePath: String, captureTimeMillis: Int64?, displayName: String?) -> [String: Any] in
|
|
415
|
+
let fileURL = URL(fileURLWithPath: filePath)
|
|
416
|
+
|
|
417
|
+
guard FileManager.default.fileExists(atPath: filePath) else {
|
|
418
|
+
return ["success": false, "error": "File does not exist"]
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
var assetIdentifier: String?
|
|
422
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
423
|
+
var resultError: Error?
|
|
424
|
+
var creationFailed = false
|
|
425
|
+
|
|
426
|
+
PHPhotoLibrary.shared().performChanges {
|
|
427
|
+
let pathExtension = fileURL.pathExtension.lowercased()
|
|
428
|
+
|
|
429
|
+
let creationRequest: PHAssetChangeRequest
|
|
430
|
+
if ["mp4", "mov", "avi", "m4v"].contains(pathExtension) {
|
|
431
|
+
guard let req = PHAssetChangeRequest.creationRequestForAssetFromVideo(
|
|
432
|
+
atFileURL: fileURL
|
|
433
|
+
)
|
|
434
|
+
else {
|
|
435
|
+
NSLog("CrustModule: Failed to create video asset request for: \(filePath)")
|
|
436
|
+
creationFailed = true
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
creationRequest = req
|
|
440
|
+
} else {
|
|
441
|
+
guard let req = PHAssetChangeRequest.creationRequestForAssetFromImage(
|
|
442
|
+
atFileURL: fileURL
|
|
443
|
+
)
|
|
444
|
+
else {
|
|
445
|
+
NSLog("CrustModule: Failed to create image asset request for: \(filePath)")
|
|
446
|
+
creationFailed = true
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
creationRequest = req
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if let captureMillis = captureTimeMillis {
|
|
453
|
+
let captureDate = Date(
|
|
454
|
+
timeIntervalSince1970: TimeInterval(captureMillis) / 1000.0
|
|
455
|
+
)
|
|
456
|
+
creationRequest.creationDate = captureDate
|
|
457
|
+
NSLog("CrustModule: Setting creation date to: \(captureDate)")
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
|
|
461
|
+
NSLog("CrustModule: Missing placeholder for created asset")
|
|
462
|
+
creationFailed = true
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
assetIdentifier = assetPlaceholder.localIdentifier
|
|
467
|
+
|
|
468
|
+
let albumFetch = PHFetchOptions()
|
|
469
|
+
albumFetch.predicate = NSPredicate(
|
|
470
|
+
format: "localizedTitle == %@", MentraSyncedMediaAlbum.localizedTitle
|
|
471
|
+
)
|
|
472
|
+
albumFetch.fetchLimit = 1
|
|
473
|
+
|
|
474
|
+
let existingAlbums = PHAssetCollection.fetchAssetCollections(
|
|
475
|
+
with: .album,
|
|
476
|
+
subtype: .albumRegular,
|
|
477
|
+
options: albumFetch
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if let album = existingAlbums.firstObject,
|
|
481
|
+
let albumChange = PHAssetCollectionChangeRequest(for: album)
|
|
482
|
+
{
|
|
483
|
+
albumChange.addAssets([assetPlaceholder] as NSArray)
|
|
484
|
+
} else if existingAlbums.firstObject == nil {
|
|
485
|
+
let newAlbumChange = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(
|
|
486
|
+
withTitle: MentraSyncedMediaAlbum.localizedTitle
|
|
487
|
+
)
|
|
488
|
+
newAlbumChange.addAssets([assetPlaceholder] as NSArray)
|
|
489
|
+
} else {
|
|
490
|
+
NSLog(
|
|
491
|
+
"CrustModule: Mentra album exists but is not writable; asset saved to library only"
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
} completionHandler: { _, error in
|
|
495
|
+
resultError = error
|
|
496
|
+
semaphore.signal()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
semaphore.wait()
|
|
500
|
+
|
|
501
|
+
if creationFailed {
|
|
502
|
+
return ["success": false, "error": "Failed to create asset request - file may be corrupted or unsupported"]
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if let error = resultError {
|
|
506
|
+
NSLog("CrustModule: Error saving to gallery: \(error.localizedDescription)")
|
|
507
|
+
return ["success": false, "error": error.localizedDescription]
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
NSLog("CrustModule: Successfully saved to gallery with proper creation date")
|
|
511
|
+
return ["success": true, "identifier": assetIdentifier ?? ""]
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
extension UIColor {
|
|
518
|
+
convenience init?(hexString: String) {
|
|
519
|
+
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
520
|
+
hex = hex.replacingOccurrences(of: "#", with: "")
|
|
521
|
+
|
|
522
|
+
var rgb: UInt64 = 0
|
|
523
|
+
guard Scanner(string: hex).scanHexInt64(&rgb) else { return nil }
|
|
524
|
+
|
|
525
|
+
let length = hex.count
|
|
526
|
+
let r, g, b, a: CGFloat
|
|
527
|
+
|
|
528
|
+
if length == 6 {
|
|
529
|
+
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
|
530
|
+
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
|
531
|
+
b = CGFloat(rgb & 0x0000FF) / 255.0
|
|
532
|
+
a = 1.0
|
|
533
|
+
} else if length == 8 {
|
|
534
|
+
r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
|
|
535
|
+
g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
|
|
536
|
+
b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
|
|
537
|
+
a = CGFloat(rgb & 0x0000_00FF) / 255.0
|
|
538
|
+
} else {
|
|
539
|
+
return nil
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
/// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
|
5
|
+
/// to apply the proper styling (e.g. border radius and shadows).
|
|
6
|
+
class CrustView: ExpoView {
|
|
7
|
+
let webView = WKWebView()
|
|
8
|
+
let onLoad = EventDispatcher()
|
|
9
|
+
var delegate: WebViewDelegate?
|
|
10
|
+
|
|
11
|
+
required init(appContext: AppContext? = nil) {
|
|
12
|
+
super.init(appContext: appContext)
|
|
13
|
+
clipsToBounds = true
|
|
14
|
+
delegate = WebViewDelegate { url in
|
|
15
|
+
self.onLoad(["url": url])
|
|
16
|
+
}
|
|
17
|
+
webView.navigationDelegate = delegate
|
|
18
|
+
addSubview(webView)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override func layoutSubviews() {
|
|
22
|
+
webView.frame = bounds
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class WebViewDelegate: NSObject, WKNavigationDelegate {
|
|
27
|
+
let onUrlChange: (String) -> Void
|
|
28
|
+
|
|
29
|
+
init(onUrlChange: @escaping (String) -> Void) {
|
|
30
|
+
self.onUrlChange = onUrlChange
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func webView(_ webView: WKWebView, didFinish _: WKNavigation) {
|
|
34
|
+
if let url = webView.url {
|
|
35
|
+
onUrlChange(url.absoluteString)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|