@mentra/engine 3.2.0-dev.200 → 3.2.0-dev.206

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 (31) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  4. package/build/services/DeviceEventRouter.js +3 -7
  5. package/build/services/DeviceEventRouter.js.map +1 -1
  6. package/build/services/PhoneStreamCoordinator.d.ts +7 -20
  7. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  8. package/build/services/PhoneStreamCoordinator.js +16 -99
  9. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  10. package/build/services/asg/galleryNotices.d.ts +1 -1
  11. package/build/services/asg/galleryNotices.d.ts.map +1 -1
  12. package/build/services/asg/galleryNotices.js.map +1 -1
  13. package/build/services/asg/gallerySyncService.d.ts +14 -0
  14. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  15. package/build/services/asg/gallerySyncService.js +232 -96
  16. package/build/services/asg/gallerySyncService.js.map +1 -1
  17. package/build/services/slimStreamStatus.d.ts.map +1 -1
  18. package/build/services/slimStreamStatus.js +16 -0
  19. package/build/services/slimStreamStatus.js.map +1 -1
  20. package/package.json +7 -7
  21. package/src/generated/releaseMetadata.ts +5 -5
  22. package/src/services/DeviceEventRouter.ts +5 -9
  23. package/src/services/PhoneStreamCoordinator.ts +23 -128
  24. package/src/services/asg/galleryNotices.ts +1 -0
  25. package/src/services/asg/gallerySyncService.ts +260 -112
  26. package/src/services/slimStreamStatus.ts +10 -1
  27. package/build/services/StreamLifecycleController.d.ts +0 -85
  28. package/build/services/StreamLifecycleController.d.ts.map +0 -1
  29. package/build/services/StreamLifecycleController.js +0 -173
  30. package/build/services/StreamLifecycleController.js.map +0 -1
  31. package/src/services/StreamLifecycleController.ts +0 -243
@@ -34,6 +34,83 @@ import {emitGalleryNotice} from "./galleryNotices"
34
34
  import {galleryTransferLedger} from "./galleryTransferLedger"
35
35
  import {cameraRollExportCoordinator} from "./cameraRollExportCoordinator"
36
36
 
37
+ export type IosHotspotSsidVerification = {
38
+ status: "matched" | "mismatched" | "unavailable"
39
+ lastSeenSsid: string
40
+ /**
41
+ * Only true when reads hit a Location PERMISSION wall. `unavailable` alone is a weaker
42
+ * claim — it also covers "every read failed transiently" and "every read came back
43
+ * empty", which can happen with Location fully granted. Callers must not blame Location
44
+ * (or disable later SSID gates) on the strength of `status` alone.
45
+ */
46
+ permissionBlocked: boolean
47
+ }
48
+
49
+ /**
50
+ * react-native-wifi-reborn rejects SSID reads with a stable `code` from its CONNECT_ERRORS
51
+ * enum. Three DIFFERENT prose messages map to the same "this install will never be allowed
52
+ * to read the SSID" wall — "Cannot detect SSID because LocationPermission is Denied",
53
+ * "...is Restricted", and the bare "Permission not granted" from the not-determined path —
54
+ * so match the code, never the text.
55
+ */
56
+ const SSID_PERMISSION_ERROR_CODES = new Set([
57
+ "locationPermissionDenied",
58
+ "locationPermissionRestricted",
59
+ "locationPermissionMissing",
60
+ ])
61
+
62
+ export function isSsidPermissionError(error: unknown): boolean {
63
+ const code = (error as {code?: unknown} | null | undefined)?.code
64
+ // A bridged `code` is authoritative: `couldNotDetectSSID` is a transient read failure,
65
+ // NOT a permission wall, and must keep polling.
66
+ if (typeof code === "string") return SSID_PERMISSION_ERROR_CODES.has(code)
67
+
68
+ // Fallback for errors that reach us with only a message (older bridges, test doubles).
69
+ const message = error instanceof Error ? error.message : String(error)
70
+ return /location\s*permission/i.test(message) || /permission not granted/i.test(message)
71
+ }
72
+
73
+ export async function verifyIosHotspotSsid(
74
+ targetSsid: string,
75
+ readCurrentSsid: (attempt: number) => Promise<string>,
76
+ sleep: () => Promise<void>,
77
+ maxAttempts = 30,
78
+ ): Promise<IosHotspotSsidVerification> {
79
+ let lastSeenSsid = "unknown"
80
+ let observedSsid = false
81
+
82
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
83
+ try {
84
+ const currentSsid = await readCurrentSsid(attempt + 1)
85
+ // Only a NON-EMPTY read proves we can see the network. An empty SSID is an
86
+ // "unknown", not a mismatch — reporting it as a mismatch would hard-fail the sync
87
+ // for the same reason this fallback exists.
88
+ if (currentSsid) {
89
+ observedSsid = true
90
+ lastSeenSsid = currentSsid
91
+ } else {
92
+ lastSeenSsid = "null"
93
+ }
94
+
95
+ if (currentSsid && currentSsid === targetSsid) {
96
+ return {status: "matched", lastSeenSsid, permissionBlocked: false}
97
+ }
98
+ } catch (error) {
99
+ lastSeenSsid = "error"
100
+ if (isSsidPermissionError(error)) {
101
+ return {status: "unavailable", lastSeenSsid, permissionBlocked: true}
102
+ }
103
+ }
104
+
105
+ if (attempt < maxAttempts - 1) await sleep()
106
+ }
107
+
108
+ // Falling out of the loop without a single legible SSID is "we never saw the network",
109
+ // NOT "we are not allowed to look" — transient read failures and empty reads land here
110
+ // with Location granted, so permissionBlocked stays false.
111
+ return {status: observedSsid ? "mismatched" : "unavailable", lastSeenSsid, permissionBlocked: false}
112
+ }
113
+
37
114
  // Timing constants
38
115
  const TIMING = {
39
116
  HOTSPOT_CONNECT_DELAY_MS: 3000, // Increased from 1000ms - hotspot needs time to broadcast and become discoverable
@@ -79,6 +156,11 @@ class GallerySyncService {
79
156
  private wifiSettingsOpenedAt: number | null = null // Timestamp when user was sent to WiFi settings
80
157
  private syncStartPromise: Promise<void> | null = null
81
158
  private startAborted = false
159
+ // Authoritative answer to "can this run read the phone's WiFi SSID?", captured from the
160
+ // Location permission gate in pre-flight. Every SSID comparison below is advisory only:
161
+ // when this is false we skip the read entirely and lean on the glasses connectivity
162
+ // probe instead of failing the sync. Assume readable until pre-flight says otherwise.
163
+ private ssidReadable = true
82
164
 
83
165
  private constructor() {}
84
166
 
@@ -160,6 +242,7 @@ class GallerySyncService {
160
242
  this.waitingForWifiRetry = false
161
243
  this.wifiSettingsOpenedAt = null
162
244
  this.startAborted = false
245
+ this.ssidReadable = true
163
246
  this.isInitialized = false
164
247
  console.log("[GallerySyncService] Cleaned up")
165
248
  }
@@ -522,19 +605,23 @@ class GallerySyncService {
522
605
  }
523
606
  console.log("[GallerySyncService] ✅ Notification permission handled")
524
607
 
525
- // 2. Location permission (required to read WiFi SSID for hotspot verification)
608
+ // 2. Location permission (required to read WiFi SSID for hotspot verification).
609
+ // Sync does NOT block on this: record the answer once, here, and let every downstream
610
+ // SSID check consult `this.ssidReadable` instead of re-deriving it from a native error.
526
611
  console.log("[GallerySyncService] 📍 Checking location permission...")
527
612
  const hasLocationPermission = await permissions.check(PermissionFeatures.LOCATION)
528
613
  if (!hasLocationPermission) {
529
614
  console.log("[GallerySyncService] ⚠️ Location permission not granted - requesting...")
530
615
  const granted = await permissions.request(PermissionFeatures.LOCATION)
616
+ this.ssidReadable = granted
531
617
  if (!granted) {
532
- console.warn("[GallerySyncService] ❌ Location permission denied - WiFi SSID verification may fail")
533
- // Don't block sync - we'll try anyway and fall back to IP-based verification if needed
618
+ console.warn("[GallerySyncService] ❌ Location permission denied - skipping all SSID verification")
619
+ console.warn("[GallerySyncService] ➡️ Falling back to the glasses connectivity probe")
534
620
  } else {
535
621
  console.log("[GallerySyncService] ✅ Location permission granted")
536
622
  }
537
623
  } else {
624
+ this.ssidReadable = true
538
625
  console.log("[GallerySyncService] ✅ Location permission already granted")
539
626
  }
540
627
  if (this.shouldAbortPreFlight()) {
@@ -739,34 +826,51 @@ class GallerySyncService {
739
826
  : null
740
827
 
741
828
  let isAlreadyConnected = false
829
+ // Distinct from `!isAlreadyConnected`: we could not READ the SSID, so we know nothing
830
+ // about which network the phone is on. Re-requesting the hotspot in that state costs a
831
+ // pointless BLE round-trip on every sync, so route straight to the connect path instead.
832
+ let hotspotMembershipUnknown = false
742
833
  if (currentGlassesHotspot) {
743
834
  console.log("[GallerySyncService] 📊 Glasses hotspot status:")
744
835
  console.log("[GallerySyncService] - Enabled: true")
745
836
  console.log(`[GallerySyncService] - SSID: ${currentGlassesHotspot.ssid}`)
746
837
  console.log(`[GallerySyncService] - IP: ${currentGlassesHotspot.ip}`)
747
838
 
748
- try {
749
- const currentSSID = await WifiManager.getCurrentWifiSSID()
750
- if (this.shouldAbortPreFlight()) {
751
- console.log("[GallerySyncService] Pre-flight aborted after SSID check")
752
- return
753
- }
754
- console.log(`[GallerySyncService] 📱 Phone current WiFi SSID: "${currentSSID}"`)
755
- console.log(`[GallerySyncService] 🔍 Comparing with glasses hotspot SSID: "${currentGlassesHotspot.ssid}"`)
756
-
757
- isAlreadyConnected = currentSSID === currentGlassesHotspot.ssid
758
- if (isAlreadyConnected) {
759
- console.log("[GallerySyncService] Phone is already connected to glasses hotspot!")
760
- } else if (currentSSID) {
761
- console.log(`[GallerySyncService] ⚠️ Phone is on different network (${currentSSID})`)
762
- console.log("[GallerySyncService] ➡️ Will request hotspot connection")
763
- } else {
764
- console.log("[GallerySyncService] ⚠️ Phone not connected to any WiFi network")
839
+ if (!this.ssidReadable) {
840
+ console.log("[GallerySyncService] ⚠️ SSID unreadable (Location denied) - cannot tell if already joined")
841
+ console.log("[GallerySyncService] ➡️ Will attempt the glasses WiFi connection directly")
842
+ hotspotMembershipUnknown = true
843
+ } else {
844
+ try {
845
+ const currentSSID = await WifiManager.getCurrentWifiSSID()
846
+ if (this.shouldAbortPreFlight()) {
847
+ console.log("[GallerySyncService] Pre-flight aborted after SSID check")
848
+ return
849
+ }
850
+ console.log(`[GallerySyncService] 📱 Phone current WiFi SSID: "${currentSSID}"`)
851
+ console.log(`[GallerySyncService] 🔍 Comparing with glasses hotspot SSID: "${currentGlassesHotspot.ssid}"`)
852
+
853
+ isAlreadyConnected = currentSSID === currentGlassesHotspot.ssid
854
+ if (isAlreadyConnected) {
855
+ console.log("[GallerySyncService] Phone is already connected to glasses hotspot!")
856
+ } else if (currentSSID) {
857
+ console.log(`[GallerySyncService] ⚠️ Phone is on different network (${currentSSID})`)
858
+ console.log("[GallerySyncService] ➡️ Will request hotspot connection")
859
+ } else {
860
+ console.log("[GallerySyncService] ⚠️ Phone not connected to any WiFi network")
861
+ }
862
+ } catch (error) {
863
+ console.warn("[GallerySyncService] ⚠️ Could not verify current WiFi SSID:", error)
864
+ isAlreadyConnected = false
865
+ // Permission was granted at pre-flight but the read still hit a wall (revoked
866
+ // between the two, or a platform we mis-predicted). Latch it so the rest of this
867
+ // sync stops paying for reads that cannot succeed, and treat membership as
868
+ // unknown rather than "definitely not joined".
869
+ if (isSsidPermissionError(error)) {
870
+ this.ssidReadable = false
871
+ hotspotMembershipUnknown = true
872
+ }
765
873
  }
766
- } catch (error) {
767
- console.warn("[GallerySyncService] ⚠️ Could not verify current WiFi SSID:", error)
768
- // If we can't verify, don't assume we're connected - request hotspot
769
- isAlreadyConnected = false
770
874
  }
771
875
  } else {
772
876
  console.log("[GallerySyncService] ℹ️ Glasses hotspot not currently enabled")
@@ -777,6 +881,18 @@ class GallerySyncService {
777
881
  // in-memory queue; startFileDownload immediately recovers its durable ledger work.
778
882
  mediaProcessingQueue.reset()
779
883
 
884
+ // SSID unreadable + hotspot already up: connectToHotspotWifi handles BOTH "already
885
+ // joined" (the native connect resolves immediately) and "needs to join", and it still
886
+ // probes the glasses server before downloading. Skipping straight to startFileDownload
887
+ // would be unsafe here — nothing has proven connectivity yet.
888
+ if (hotspotMembershipUnknown && currentGlassesHotspot) {
889
+ const hotspotInfo: HotspotInfo = currentGlassesHotspot
890
+ store.setHotspotInfo(hotspotInfo)
891
+ store.setSyncState("connecting_wifi")
892
+ await this.connectToHotspotWifi(hotspotInfo)
893
+ return
894
+ }
895
+
780
896
  if (isAlreadyConnected && currentGlassesHotspot) {
781
897
  const hotspotInfo: HotspotInfo = currentGlassesHotspot
782
898
  store.setHotspotInfo(hotspotInfo)
@@ -936,30 +1052,38 @@ class GallerySyncService {
936
1052
  console.log(`[GallerySyncService] ⏱️ Time since WiFi phase started: ${Date.now() - wifiConnectStartTime}ms`)
937
1053
  console.log(`[GallerySyncService] 📱 App backgrounded during connection: ${appBackgrounded}`)
938
1054
 
939
- // Check current WiFi state before attempting connection
940
- let preConnectSSID = "unknown"
941
- try {
942
- preConnectSSID = await WifiManager.getCurrentWifiSSID()
943
- console.log(`[GallerySyncService] 📡 Current WiFi SSID: "${preConnectSSID}"`)
944
-
945
- // Check if already connected (shouldn't happen, but good to verify)
946
- if (!localNetworkTransport.supportsScopedConnection() && preConnectSSID === hotspotInfo.ssid) {
947
- console.log("[GallerySyncService] Already connected to target SSID! Proceeding to download.")
948
- appStateSubscription.remove()
949
-
950
- const totalWifiDuration = Date.now() - wifiConnectStartTime
951
- console.log("[GallerySyncService] ========================================")
952
- console.log("[GallerySyncService] ✅ WIFI CONNECTION COMPLETE (already connected)")
953
- console.log("[GallerySyncService] ========================================")
954
- console.log(`[GallerySyncService] ⏱️ Total WiFi phase duration: ${totalWifiDuration}ms`)
955
- console.log(`[GallerySyncService] 🚀 Proceeding to file download from ${hotspotInfo.ip}:8089`)
956
-
957
- await this.startFileDownload(hotspotInfo)
958
- return // Exit function successfully
1055
+ // Check current WiFi state before attempting connection. This is a shortcut, not
1056
+ // a gate — when the SSID is unreadable we simply fall through to the connect call,
1057
+ // which is itself a no-op if the phone is already on the target network.
1058
+ if (!this.ssidReadable) {
1059
+ console.log("[GallerySyncService] 📡 Skipping pre-connect SSID read (Location denied)")
1060
+ } else {
1061
+ try {
1062
+ const preConnectSSID = await WifiManager.getCurrentWifiSSID()
1063
+ console.log(`[GallerySyncService] 📡 Current WiFi SSID: "${preConnectSSID}"`)
1064
+
1065
+ // Check if already connected (shouldn't happen, but good to verify)
1066
+ if (!localNetworkTransport.supportsScopedConnection() && preConnectSSID === hotspotInfo.ssid) {
1067
+ console.log("[GallerySyncService] ✅ Already connected to target SSID! Proceeding to download.")
1068
+ appStateSubscription.remove()
1069
+
1070
+ const totalWifiDuration = Date.now() - wifiConnectStartTime
1071
+ console.log("[GallerySyncService] ========================================")
1072
+ console.log("[GallerySyncService] ✅ WIFI CONNECTION COMPLETE (already connected)")
1073
+ console.log("[GallerySyncService] ========================================")
1074
+ console.log(`[GallerySyncService] ⏱️ Total WiFi phase duration: ${totalWifiDuration}ms`)
1075
+ console.log(`[GallerySyncService] 🚀 Proceeding to file download from ${hotspotInfo.ip}:8089`)
1076
+
1077
+ await this.startFileDownload(hotspotInfo)
1078
+ return // Exit function successfully
1079
+ }
1080
+ } catch (preError: unknown) {
1081
+ const message = preError instanceof Error ? preError.message : String(preError)
1082
+ console.warn(`[GallerySyncService] ⚠️ Could not get current SSID: ${message}`)
1083
+ console.warn("[GallerySyncService] ⚠️ Error code:", (preError as {code?: unknown} | null)?.code)
1084
+ // A permission wall here applies to every later read too — stop paying for it.
1085
+ if (isSsidPermissionError(preError)) this.ssidReadable = false
959
1086
  }
960
- } catch (preError: any) {
961
- console.warn(`[GallerySyncService] ⚠️ Could not get current SSID: ${preError?.message}`)
962
- console.warn("[GallerySyncService] ⚠️ Error code:", preError?.code)
963
1087
  }
964
1088
 
965
1089
  console.log(`[GallerySyncService] 🔌 Connecting the glasses-local transport...`)
@@ -984,64 +1108,67 @@ class GallerySyncService {
984
1108
  `[GallerySyncService] ⏱️ Time until backgrounding: ${appBackgroundTime - connectCallStartTime}ms`,
985
1109
  )
986
1110
  }
987
- console.log(`[GallerySyncService] 📝 Note: On iOS, this does NOT guarantee actual connection!`)
1111
+ // NOTE: react-native-wifi-reborn >= 4.x already polls the SSID natively inside
1112
+ // connectToProtectedSSID (up to 20 x 500ms) and only resolves once it observes the
1113
+ // target network, so reaching this line is itself decent evidence of a real join.
1114
+ // The poll below is a second opinion, not the primary gate — never fail on it
1115
+ // when the SSID simply cannot be read.
988
1116
 
989
- // iOS-specific: Verify actual WiFi connection by polling SSID
990
- // The library promise resolves when iOS ACCEPTS the request, not when connection completes
991
- if (Platform.OS === "ios") {
1117
+ if (Platform.OS === "ios" && !this.ssidReadable) {
1118
+ console.log(
1119
+ `[GallerySyncService] 🍎 Skipping SSID verification (Location denied) - using glasses connectivity probe`,
1120
+ )
1121
+ } else if (Platform.OS === "ios") {
992
1122
  console.log(`[GallerySyncService] 🍎 iOS: Starting connection verification...`)
993
1123
  console.log(`[GallerySyncService] 🍎 Will poll getCurrentWifiSSID() for up to 15 seconds`)
994
1124
 
995
1125
  const maxVerifyAttempts = 30 // 30 × 500ms = 15 seconds
996
- let connected = false
997
- let lastSeenSSID = "unknown"
998
-
999
- for (let i = 0; i < maxVerifyAttempts; i++) {
1000
- try {
1001
- const currentSSID = await WifiManager.getCurrentWifiSSID()
1002
- lastSeenSSID = currentSSID || "null"
1003
-
1004
- console.log(
1005
- `[GallerySyncService] 🍎 Verify poll ${
1006
- i + 1
1007
- }/${maxVerifyAttempts}: Current="${currentSSID}", Target="${hotspotInfo.ssid}"`,
1008
- )
1009
-
1010
- if (currentSSID === hotspotInfo.ssid) {
1011
- console.log(
1012
- `[GallerySyncService] 🍎 ✅ VERIFICATION SUCCESS! Connected to target network after ${
1013
- (i + 1) * 500
1014
- }ms`,
1015
- )
1016
- connected = true
1017
- break
1018
- } else if (i === 0 && currentSSID === lastSeenSSID) {
1126
+ const verification = await verifyIosHotspotSsid(
1127
+ hotspotInfo.ssid,
1128
+ async (pollNumber) => {
1129
+ try {
1130
+ const currentSsid = await WifiManager.getCurrentWifiSSID()
1019
1131
  console.log(
1020
- `[GallerySyncService] 🍎 ⚠️ Still on original network - iOS dialog may not have appeared yet`,
1132
+ `[GallerySyncService] 🍎 Verify poll ${pollNumber}/${maxVerifyAttempts}: Current="${currentSsid}", Target="${hotspotInfo.ssid}"`,
1021
1133
  )
1134
+ return currentSsid
1135
+ } catch (error: unknown) {
1136
+ const message = error instanceof Error ? error.message : String(error)
1137
+ console.log(`[GallerySyncService] 🍎 ⚠️ Poll ${pollNumber}: Could not check SSID: ${message}`)
1138
+ throw error
1022
1139
  }
1023
- } catch (ssidError: any) {
1024
- console.log(`[GallerySyncService] 🍎 ⚠️ Poll ${i + 1}: Could not check SSID: ${ssidError?.message}`)
1025
- lastSeenSSID = "error"
1026
- }
1027
-
1028
- // Don't wait after last attempt
1029
- if (i < maxVerifyAttempts - 1) {
1030
- await new Promise<void>((resolve) => BgTimer.setTimeout(() => resolve(), 500))
1031
- }
1032
- }
1140
+ },
1141
+ () => new Promise<void>((resolve) => BgTimer.setTimeout(() => resolve(), 500)),
1142
+ maxVerifyAttempts,
1143
+ )
1033
1144
 
1034
- if (!connected) {
1145
+ if (verification.status === "mismatched") {
1035
1146
  console.error(`[GallerySyncService] 🍎 ❌ VERIFICATION FAILED after 15 seconds`)
1036
- console.error(`[GallerySyncService] 🍎 Last seen SSID: "${lastSeenSSID}"`)
1147
+ console.error(`[GallerySyncService] 🍎 Last seen SSID: "${verification.lastSeenSsid}"`)
1037
1148
  console.error(`[GallerySyncService] 🍎 Expected SSID: "${hotspotInfo.ssid}"`)
1038
1149
  console.error(`[GallerySyncService] 🍎 Possible causes:`)
1039
1150
  console.error(`[GallerySyncService] 🍎 1. User did not tap "Join" on iOS WiFi dialog`)
1040
- console.error(`[GallerySyncService] 🍎 2. iOS dialog did not appear (permission issue?)`)
1151
+ console.error(`[GallerySyncService] 🍎 2. iOS dialog did not appear`)
1041
1152
  console.error(`[GallerySyncService] 🍎 3. iOS refused to switch networks`)
1042
1153
  throw new Error(
1043
- `iOS WiFi verification failed - still on "${lastSeenSSID}", expected "${hotspotInfo.ssid}"`,
1154
+ `iOS WiFi verification failed - still on "${verification.lastSeenSsid}", expected "${hotspotInfo.ssid}"`,
1155
+ )
1156
+ }
1157
+
1158
+ if (verification.status === "unavailable") {
1159
+ console.warn(
1160
+ `[GallerySyncService] 🍎 SSID inspection unavailable; using glasses connectivity probe instead`,
1044
1161
  )
1162
+ // Latch ONLY on a real permission wall (revoked between pre-flight and now).
1163
+ // A run of transient or empty reads must leave `ssidReadable` alone: killing
1164
+ // it here would disable the readable-mismatch gate for the remaining retries
1165
+ // and make a later failure blame Location while it is granted.
1166
+ if (verification.permissionBlocked) {
1167
+ console.warn(`[GallerySyncService] 🍎 Location permission was revoked mid-sync - skipping SSID gates`)
1168
+ this.ssidReadable = false
1169
+ }
1170
+ } else {
1171
+ console.log(`[GallerySyncService] 🍎 ✅ SSID verification succeeded`)
1045
1172
  }
1046
1173
  }
1047
1174
 
@@ -1054,25 +1181,35 @@ class GallerySyncService {
1054
1181
  appStateSubscription.remove()
1055
1182
  console.log("[GallerySyncService] 👂 App state listener removed")
1056
1183
 
1057
- // Final verification: Check SSID one more time before starting download
1058
- try {
1059
- const finalSSID = localNetworkTransport.isScopedConnectionActive()
1060
- ? hotspotInfo.ssid
1061
- : await WifiManager.getCurrentWifiSSID()
1062
- console.log(`[GallerySyncService] 📶 Final SSID check before download: "${finalSSID}"`)
1063
- if (Platform.OS === "android") {
1064
- // Some local builds can have stale generated typings for the Bluetooth SDK module.
1065
- ;(BluetoothSdk as any).logCurrentWifiFrequency?.()
1066
- }
1067
- if (finalSSID !== hotspotInfo.ssid) {
1068
- console.error(
1069
- `[GallerySyncService] SSID mismatch detected! Expected "${hotspotInfo.ssid}", got "${finalSSID}"`,
1070
- )
1071
- throw new Error(`WiFi SSID mismatch - connected to "${finalSSID}" instead of "${hotspotInfo.ssid}"`)
1184
+ // Final verification: Check SSID one more time before starting download.
1185
+ // Only the READ is allowed to fail softly. A readable SSID that does not match is
1186
+ // a real "we are about to download from the wrong network" signal and must abort —
1187
+ // previously the throw was inside the try, so its own catch swallowed it and the
1188
+ // gate never actually stopped anything.
1189
+ let finalSSID: string | null = null
1190
+ if (!this.ssidReadable) {
1191
+ console.log("[GallerySyncService] 📶 Skipping final SSID check (Location denied)")
1192
+ } else {
1193
+ try {
1194
+ finalSSID = localNetworkTransport.isScopedConnectionActive()
1195
+ ? hotspotInfo.ssid
1196
+ : await WifiManager.getCurrentWifiSSID()
1197
+ console.log(`[GallerySyncService] 📶 Final SSID check before download: "${finalSSID}"`)
1198
+ } catch (finalError: unknown) {
1199
+ const message = finalError instanceof Error ? finalError.message : String(finalError)
1200
+ console.warn(`[GallerySyncService] ⚠️ Could not perform final SSID check: ${message}`)
1201
+ // Continue anyway - we've done our best to verify
1072
1202
  }
1073
- } catch (finalError: any) {
1074
- console.warn(`[GallerySyncService] ⚠️ Could not perform final SSID check: ${finalError?.message}`)
1075
- // Continue anyway - we've done our best to verify
1203
+ }
1204
+ if (Platform.OS === "android") {
1205
+ // Some local builds can have stale generated typings for the Bluetooth SDK module.
1206
+ ;(BluetoothSdk as any).logCurrentWifiFrequency?.()
1207
+ }
1208
+ if (finalSSID && finalSSID !== hotspotInfo.ssid) {
1209
+ console.error(
1210
+ `[GallerySyncService] ❌ SSID mismatch detected! Expected "${hotspotInfo.ssid}", got "${finalSSID}"`,
1211
+ )
1212
+ throw new Error(`WiFi SSID mismatch - connected to "${finalSSID}" instead of "${hotspotInfo.ssid}"`)
1076
1213
  }
1077
1214
 
1078
1215
  // iOS-specific: Wait for actual network connectivity to glasses
@@ -1086,19 +1223,22 @@ class GallerySyncService {
1086
1223
  let networkReady = false
1087
1224
 
1088
1225
  for (let probeNum = 1; probeNum <= maxProbeAttempts; probeNum++) {
1226
+ // Declared outside the try so the `finally` can always clear it — the fetch
1227
+ // rejects on most early probes, and an un-cleared BgTimer is a live native
1228
+ // timer (up to 20 per attempt, 100 per sync).
1229
+ const probeController = new AbortController()
1230
+ let probeTimeout: number | null = null
1089
1231
  try {
1090
1232
  console.log(`[GallerySyncService] 🍎 Connectivity probe ${probeNum}/${maxProbeAttempts}...`)
1091
1233
 
1092
1234
  // Try to reach the glasses health endpoint with a short timeout
1093
- const probeController = new AbortController()
1094
- const probeTimeout = BgTimer.setTimeout(() => probeController.abort(), 1000) // 1 second timeout per probe
1235
+ probeTimeout = BgTimer.setTimeout(() => probeController.abort(), 1000) // 1 second timeout per probe
1095
1236
 
1096
1237
  const probeStartTime = Date.now()
1097
1238
  const probeResponse = await localNetworkTransport.fetch(`http://${hotspotInfo.ip}:8089/api/health`, {
1098
1239
  method: "GET",
1099
1240
  signal: probeController.signal,
1100
1241
  })
1101
- BgTimer.clearTimeout(probeTimeout)
1102
1242
 
1103
1243
  const probeDuration = Date.now() - probeStartTime
1104
1244
  console.log(
@@ -1112,14 +1252,16 @@ class GallerySyncService {
1112
1252
  networkReady = true
1113
1253
  break
1114
1254
  }
1115
- } catch (probeError: any) {
1116
- const errorMsg = probeError?.message || "unknown"
1255
+ } catch (probeError: unknown) {
1256
+ const errorMsg = probeError instanceof Error ? probeError.message : String(probeError)
1117
1257
  console.log(
1118
1258
  `[GallerySyncService] 🍎 Probe ${probeNum} failed: ${errorMsg.substring(0, 50)}${
1119
1259
  errorMsg.length > 50 ? "..." : ""
1120
1260
  }`,
1121
1261
  )
1122
1262
  // Continue to next probe
1263
+ } finally {
1264
+ if (probeTimeout !== null) BgTimer.clearTimeout(probeTimeout)
1123
1265
  }
1124
1266
 
1125
1267
  // Wait 500ms before next probe (unless this was the last attempt)
@@ -1288,6 +1430,12 @@ class GallerySyncService {
1288
1430
  } else if (lastError?.message?.includes("internal error")) {
1289
1431
  userErrorMessage =
1290
1432
  "Could not connect to glasses WiFi. Please ensure you accept the WiFi prompt when it appears."
1433
+ } else if (!this.ssidReadable) {
1434
+ // We fell back to the connectivity probe because Location was denied, and the probe
1435
+ // did not reach the glasses either. Point the user at the actionable cause rather
1436
+ // than a raw "could not reach 192.168.43.1:8089".
1437
+ userErrorMessage = "Could not reach glasses over WiFi. Allow Location access so the app can verify the network."
1438
+ emitGalleryNotice({code: "location_permission_required"})
1291
1439
  }
1292
1440
 
1293
1441
  store.setSyncError(userErrorMessage)
@@ -22,7 +22,16 @@ export function slimStreamStatusEvent(
22
22
  kind: event.kind,
23
23
  status: event.status,
24
24
  }
25
- if (event.streamId) slim.streamId = event.streamId
25
+ if (event.streamId) slim.streamId = event.streamId
26
+ if (event.sid) slim.sid = event.sid
27
+ if (typeof event.revision === "number") slim.revision = event.revision
28
+ if (typeof event.terminal === "boolean") slim.terminal = event.terminal
29
+ if (event.errorDetails) slim.errorDetails = event.errorDetails
30
+ if (event.kind === "reconnect") {
31
+ if ("reason" in event) slim.reason = event.reason
32
+ if ("attempt" in event) slim.attempt = event.attempt
33
+ if ("maxAttempts" in event) slim.maxAttempts = event.maxAttempts
34
+ }
26
35
  const ts = event.timestamp
27
36
  if (typeof ts === "number" && Number.isFinite(ts)) slim.timestamp = ts
28
37
  if (options.includeResolvedConfig && event.resolvedConfig) {
@@ -1,85 +0,0 @@
1
- /**
2
- * Drives stream liveliness checks via a keep-alive heartbeat:
3
- * `setActive(true)` starts the timer; each tick sends a keep-alive with a
4
- * fresh ackId; `maxMissedAcks` consecutive timeouts fire `onTimeout`.
5
- *
6
- * Phone-owned successor to the retired cloud stream lifecycle controller.
7
- * It uses the local `LifecycleLogger` interface because pino is not a phone
8
- * dependency.
9
- *
10
- * Timers MUST go through BgTimer. React Native pauses plain `setInterval`
11
- * while MentraOS is backgrounded; glasses WHIP then auto-stops after 60s
12
- * without `keep_stream_alive`, and Mentra Call recovers by restarting ingest.
13
- */
14
- export interface LifecycleLogger {
15
- child(bindings: Record<string, unknown>): LifecycleLogger;
16
- debug(...args: unknown[]): void;
17
- warn(...args: unknown[]): void;
18
- error(...args: unknown[]): void;
19
- }
20
- interface StreamLifecycleCallbacks {
21
- sendKeepAlive: (ackId: string) => Promise<void> | void;
22
- onTimeout: () => Promise<void> | void;
23
- onKeepAliveSent?: (ackId: string) => void;
24
- onKeepAliveAcked?: (ackId: string, ageMs: number) => void;
25
- onKeepAliveMissed?: (ackId: string, ageMs: number, missedCount: number) => void;
26
- }
27
- export interface StreamLifecycleTimerApi {
28
- setInterval: (callback: () => void, delay: number) => number;
29
- clearInterval: (intervalId: number) => void;
30
- setTimeout: (callback: () => void, delay: number) => number;
31
- clearTimeout: (timeoutId: number) => void;
32
- }
33
- export interface StreamLifecycleOptions {
34
- logger: LifecycleLogger;
35
- streamId: string;
36
- keepAliveIntervalMs: number;
37
- ackTimeoutMs: number;
38
- maxMissedAcks: number;
39
- shouldSendKeepAlive?: () => boolean;
40
- now?: () => number;
41
- timers?: StreamLifecycleTimerApi;
42
- }
43
- /**
44
- * Keep-alive heartbeat. Activate to start ticks; each tick sends keep-alive
45
- * with a fresh ackId and arms a timeout. `maxMissedAcks` consecutive misses
46
- * fire `onTimeout`, and the caller is expected to tear the stream down.
47
- */
48
- export declare class StreamLifecycleController {
49
- private readonly callbacks;
50
- private keepAliveTimer?;
51
- private pendingAcks;
52
- private missedAcks;
53
- private lastActivityMs;
54
- private active;
55
- private disposed;
56
- private readonly logger;
57
- private readonly streamId;
58
- private readonly keepAliveIntervalMs;
59
- private readonly ackTimeoutMs;
60
- private readonly maxMissedAcks;
61
- private readonly shouldSendKeepAlive?;
62
- private readonly now;
63
- private readonly timers;
64
- constructor(options: StreamLifecycleOptions, callbacks: StreamLifecycleCallbacks);
65
- setActive(active: boolean): void;
66
- recordActivity(): void;
67
- /**
68
- * Send one keep-alive immediately instead of waiting for the next interval.
69
- * Used when the BLE link comes back after a suspension: the glasses
70
- * publisher's 60s watchdog has been running the whole time, so the first
71
- * heartbeat after resume must not wait up to another full interval.
72
- */
73
- tickNow(): void;
74
- handleAck(ackId: string): void;
75
- dispose(): void;
76
- getLastActivityMs(): number;
77
- private startTimer;
78
- private stopTimer;
79
- private tick;
80
- private onAckTimeout;
81
- private clearPendingAcks;
82
- private createAckId;
83
- }
84
- export {};
85
- //# sourceMappingURL=StreamLifecycleController.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"StreamLifecycleController.d.ts","sourceRoot":"","sources":["../../src/services/StreamLifecycleController.ts"],"names":[],"mappings":"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"}