@mentra/engine 3.2.0-dev.195 → 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.
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +3 -7
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +7 -20
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +16 -99
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/asg/galleryNotices.d.ts +1 -1
- package/build/services/asg/galleryNotices.d.ts.map +1 -1
- package/build/services/asg/galleryNotices.js.map +1 -1
- package/build/services/asg/gallerySyncService.d.ts +14 -0
- package/build/services/asg/gallerySyncService.d.ts.map +1 -1
- package/build/services/asg/gallerySyncService.js +232 -96
- package/build/services/asg/gallerySyncService.js.map +1 -1
- package/build/services/slimStreamStatus.d.ts.map +1 -1
- package/build/services/slimStreamStatus.js +16 -0
- package/build/services/slimStreamStatus.js.map +1 -1
- package/package.json +7 -7
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/DeviceEventRouter.ts +5 -9
- package/src/services/PhoneStreamCoordinator.ts +23 -128
- package/src/services/asg/galleryNotices.ts +1 -0
- package/src/services/asg/gallerySyncService.ts +260 -112
- package/src/services/slimStreamStatus.ts +10 -1
- package/build/services/StreamLifecycleController.d.ts +0 -85
- package/build/services/StreamLifecycleController.d.ts.map +0 -1
- package/build/services/StreamLifecycleController.js +0 -173
- package/build/services/StreamLifecycleController.js.map +0 -1
- 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 -
|
|
533
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
console.log(
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
console.log(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
console.log(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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] 🍎
|
|
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
|
-
}
|
|
1024
|
-
|
|
1025
|
-
|
|
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 (
|
|
1145
|
+
if (verification.status === "mismatched") {
|
|
1035
1146
|
console.error(`[GallerySyncService] 🍎 ❌ VERIFICATION FAILED after 15 seconds`)
|
|
1036
|
-
console.error(`[GallerySyncService] 🍎 Last seen SSID: "${
|
|
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
|
|
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 "${
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
)
|
|
1071
|
-
|
|
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
|
-
}
|
|
1074
|
-
|
|
1075
|
-
//
|
|
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
|
-
|
|
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:
|
|
1116
|
-
const errorMsg = probeError
|
|
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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"}
|