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

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
@@ -29,6 +29,62 @@ import { validateCaptureMetadataForDownload } from "./galleryMediaValidation";
29
29
  import { emitGalleryNotice } from "./galleryNotices";
30
30
  import { galleryTransferLedger } from "./galleryTransferLedger";
31
31
  import { cameraRollExportCoordinator } from "./cameraRollExportCoordinator";
32
+ /**
33
+ * react-native-wifi-reborn rejects SSID reads with a stable `code` from its CONNECT_ERRORS
34
+ * enum. Three DIFFERENT prose messages map to the same "this install will never be allowed
35
+ * to read the SSID" wall — "Cannot detect SSID because LocationPermission is Denied",
36
+ * "...is Restricted", and the bare "Permission not granted" from the not-determined path —
37
+ * so match the code, never the text.
38
+ */
39
+ const SSID_PERMISSION_ERROR_CODES = new Set([
40
+ "locationPermissionDenied",
41
+ "locationPermissionRestricted",
42
+ "locationPermissionMissing",
43
+ ]);
44
+ export function isSsidPermissionError(error) {
45
+ const code = error?.code;
46
+ // A bridged `code` is authoritative: `couldNotDetectSSID` is a transient read failure,
47
+ // NOT a permission wall, and must keep polling.
48
+ if (typeof code === "string")
49
+ return SSID_PERMISSION_ERROR_CODES.has(code);
50
+ // Fallback for errors that reach us with only a message (older bridges, test doubles).
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ return /location\s*permission/i.test(message) || /permission not granted/i.test(message);
53
+ }
54
+ export async function verifyIosHotspotSsid(targetSsid, readCurrentSsid, sleep, maxAttempts = 30) {
55
+ let lastSeenSsid = "unknown";
56
+ let observedSsid = false;
57
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
58
+ try {
59
+ const currentSsid = await readCurrentSsid(attempt + 1);
60
+ // Only a NON-EMPTY read proves we can see the network. An empty SSID is an
61
+ // "unknown", not a mismatch — reporting it as a mismatch would hard-fail the sync
62
+ // for the same reason this fallback exists.
63
+ if (currentSsid) {
64
+ observedSsid = true;
65
+ lastSeenSsid = currentSsid;
66
+ }
67
+ else {
68
+ lastSeenSsid = "null";
69
+ }
70
+ if (currentSsid && currentSsid === targetSsid) {
71
+ return { status: "matched", lastSeenSsid, permissionBlocked: false };
72
+ }
73
+ }
74
+ catch (error) {
75
+ lastSeenSsid = "error";
76
+ if (isSsidPermissionError(error)) {
77
+ return { status: "unavailable", lastSeenSsid, permissionBlocked: true };
78
+ }
79
+ }
80
+ if (attempt < maxAttempts - 1)
81
+ await sleep();
82
+ }
83
+ // Falling out of the loop without a single legible SSID is "we never saw the network",
84
+ // NOT "we are not allowed to look" — transient read failures and empty reads land here
85
+ // with Location granted, so permissionBlocked stays false.
86
+ return { status: observedSsid ? "mismatched" : "unavailable", lastSeenSsid, permissionBlocked: false };
87
+ }
32
88
  // Timing constants
33
89
  const TIMING = {
34
90
  HOTSPOT_CONNECT_DELAY_MS: 3000, // Increased from 1000ms - hotspot needs time to broadcast and become discoverable
@@ -62,6 +118,11 @@ class GallerySyncService {
62
118
  wifiSettingsOpenedAt = null; // Timestamp when user was sent to WiFi settings
63
119
  syncStartPromise = null;
64
120
  startAborted = false;
121
+ // Authoritative answer to "can this run read the phone's WiFi SSID?", captured from the
122
+ // Location permission gate in pre-flight. Every SSID comparison below is advisory only:
123
+ // when this is false we skip the read entirely and lean on the glasses connectivity
124
+ // probe instead of failing the sync. Assume readable until pre-flight says otherwise.
125
+ ssidReadable = true;
65
126
  constructor() { }
66
127
  static getInstance() {
67
128
  if (!GallerySyncService.instance) {
@@ -128,6 +189,7 @@ class GallerySyncService {
128
189
  this.waitingForWifiRetry = false;
129
190
  this.wifiSettingsOpenedAt = null;
130
191
  this.startAborted = false;
192
+ this.ssidReadable = true;
131
193
  this.isInitialized = false;
132
194
  console.log("[GallerySyncService] Cleaned up");
133
195
  }
@@ -431,21 +493,25 @@ class GallerySyncService {
431
493
  return;
432
494
  }
433
495
  console.log("[GallerySyncService] ✅ Notification permission handled");
434
- // 2. Location permission (required to read WiFi SSID for hotspot verification)
496
+ // 2. Location permission (required to read WiFi SSID for hotspot verification).
497
+ // Sync does NOT block on this: record the answer once, here, and let every downstream
498
+ // SSID check consult `this.ssidReadable` instead of re-deriving it from a native error.
435
499
  console.log("[GallerySyncService] 📍 Checking location permission...");
436
500
  const hasLocationPermission = await permissions.check(PermissionFeatures.LOCATION);
437
501
  if (!hasLocationPermission) {
438
502
  console.log("[GallerySyncService] ⚠️ Location permission not granted - requesting...");
439
503
  const granted = await permissions.request(PermissionFeatures.LOCATION);
504
+ this.ssidReadable = granted;
440
505
  if (!granted) {
441
- console.warn("[GallerySyncService] ❌ Location permission denied - WiFi SSID verification may fail");
442
- // Don't block sync - we'll try anyway and fall back to IP-based verification if needed
506
+ console.warn("[GallerySyncService] ❌ Location permission denied - skipping all SSID verification");
507
+ console.warn("[GallerySyncService] ➡️ Falling back to the glasses connectivity probe");
443
508
  }
444
509
  else {
445
510
  console.log("[GallerySyncService] ✅ Location permission granted");
446
511
  }
447
512
  }
448
513
  else {
514
+ this.ssidReadable = true;
449
515
  console.log("[GallerySyncService] ✅ Location permission already granted");
450
516
  }
451
517
  if (this.shouldAbortPreFlight()) {
@@ -635,36 +701,54 @@ class GallerySyncService {
635
701
  }
636
702
  : null;
637
703
  let isAlreadyConnected = false;
704
+ // Distinct from `!isAlreadyConnected`: we could not READ the SSID, so we know nothing
705
+ // about which network the phone is on. Re-requesting the hotspot in that state costs a
706
+ // pointless BLE round-trip on every sync, so route straight to the connect path instead.
707
+ let hotspotMembershipUnknown = false;
638
708
  if (currentGlassesHotspot) {
639
709
  console.log("[GallerySyncService] 📊 Glasses hotspot status:");
640
710
  console.log("[GallerySyncService] - Enabled: true");
641
711
  console.log(`[GallerySyncService] - SSID: ${currentGlassesHotspot.ssid}`);
642
712
  console.log(`[GallerySyncService] - IP: ${currentGlassesHotspot.ip}`);
643
- try {
644
- const currentSSID = await WifiManager.getCurrentWifiSSID();
645
- if (this.shouldAbortPreFlight()) {
646
- console.log("[GallerySyncService] Pre-flight aborted after SSID check");
647
- return;
648
- }
649
- console.log(`[GallerySyncService] 📱 Phone current WiFi SSID: "${currentSSID}"`);
650
- console.log(`[GallerySyncService] 🔍 Comparing with glasses hotspot SSID: "${currentGlassesHotspot.ssid}"`);
651
- isAlreadyConnected = currentSSID === currentGlassesHotspot.ssid;
652
- if (isAlreadyConnected) {
653
- console.log("[GallerySyncService] ✅ Phone is already connected to glasses hotspot!");
654
- }
655
- else if (currentSSID) {
656
- console.log(`[GallerySyncService] ⚠️ Phone is on different network (${currentSSID})`);
657
- console.log("[GallerySyncService] ➡️ Will request hotspot connection");
713
+ if (!this.ssidReadable) {
714
+ console.log("[GallerySyncService] ⚠️ SSID unreadable (Location denied) - cannot tell if already joined");
715
+ console.log("[GallerySyncService] ➡️ Will attempt the glasses WiFi connection directly");
716
+ hotspotMembershipUnknown = true;
717
+ }
718
+ else {
719
+ try {
720
+ const currentSSID = await WifiManager.getCurrentWifiSSID();
721
+ if (this.shouldAbortPreFlight()) {
722
+ console.log("[GallerySyncService] Pre-flight aborted after SSID check");
723
+ return;
724
+ }
725
+ console.log(`[GallerySyncService] 📱 Phone current WiFi SSID: "${currentSSID}"`);
726
+ console.log(`[GallerySyncService] 🔍 Comparing with glasses hotspot SSID: "${currentGlassesHotspot.ssid}"`);
727
+ isAlreadyConnected = currentSSID === currentGlassesHotspot.ssid;
728
+ if (isAlreadyConnected) {
729
+ console.log("[GallerySyncService] ✅ Phone is already connected to glasses hotspot!");
730
+ }
731
+ else if (currentSSID) {
732
+ console.log(`[GallerySyncService] ⚠️ Phone is on different network (${currentSSID})`);
733
+ console.log("[GallerySyncService] ➡️ Will request hotspot connection");
734
+ }
735
+ else {
736
+ console.log("[GallerySyncService] ⚠️ Phone not connected to any WiFi network");
737
+ }
658
738
  }
659
- else {
660
- console.log("[GallerySyncService] ⚠️ Phone not connected to any WiFi network");
739
+ catch (error) {
740
+ console.warn("[GallerySyncService] ⚠️ Could not verify current WiFi SSID:", error);
741
+ isAlreadyConnected = false;
742
+ // Permission was granted at pre-flight but the read still hit a wall (revoked
743
+ // between the two, or a platform we mis-predicted). Latch it so the rest of this
744
+ // sync stops paying for reads that cannot succeed, and treat membership as
745
+ // unknown rather than "definitely not joined".
746
+ if (isSsidPermissionError(error)) {
747
+ this.ssidReadable = false;
748
+ hotspotMembershipUnknown = true;
749
+ }
661
750
  }
662
751
  }
663
- catch (error) {
664
- console.warn("[GallerySyncService] ⚠️ Could not verify current WiFi SSID:", error);
665
- // If we can't verify, don't assume we're connected - request hotspot
666
- isAlreadyConnected = false;
667
- }
668
752
  }
669
753
  else {
670
754
  console.log("[GallerySyncService] ℹ️ Glasses hotspot not currently enabled");
@@ -673,6 +757,17 @@ class GallerySyncService {
673
757
  // Every fallible pre-flight gate has passed. It is now safe to replace the previous
674
758
  // in-memory queue; startFileDownload immediately recovers its durable ledger work.
675
759
  mediaProcessingQueue.reset();
760
+ // SSID unreadable + hotspot already up: connectToHotspotWifi handles BOTH "already
761
+ // joined" (the native connect resolves immediately) and "needs to join", and it still
762
+ // probes the glasses server before downloading. Skipping straight to startFileDownload
763
+ // would be unsafe here — nothing has proven connectivity yet.
764
+ if (hotspotMembershipUnknown && currentGlassesHotspot) {
765
+ const hotspotInfo = currentGlassesHotspot;
766
+ store.setHotspotInfo(hotspotInfo);
767
+ store.setSyncState("connecting_wifi");
768
+ await this.connectToHotspotWifi(hotspotInfo);
769
+ return;
770
+ }
676
771
  if (isAlreadyConnected && currentGlassesHotspot) {
677
772
  const hotspotInfo = currentGlassesHotspot;
678
773
  store.setHotspotInfo(hotspotInfo);
@@ -811,28 +906,38 @@ class GallerySyncService {
811
906
  console.log(`[GallerySyncService] 📡 ATTEMPT ${attempt}/${TIMING.IOS_WIFI_MAX_RETRIES} - Starting WiFi connection`);
812
907
  console.log(`[GallerySyncService] ⏱️ Time since WiFi phase started: ${Date.now() - wifiConnectStartTime}ms`);
813
908
  console.log(`[GallerySyncService] 📱 App backgrounded during connection: ${appBackgrounded}`);
814
- // Check current WiFi state before attempting connection
815
- let preConnectSSID = "unknown";
816
- try {
817
- preConnectSSID = await WifiManager.getCurrentWifiSSID();
818
- console.log(`[GallerySyncService] 📡 Current WiFi SSID: "${preConnectSSID}"`);
819
- // Check if already connected (shouldn't happen, but good to verify)
820
- if (!localNetworkTransport.supportsScopedConnection() && preConnectSSID === hotspotInfo.ssid) {
821
- console.log("[GallerySyncService] ✅ Already connected to target SSID! Proceeding to download.");
822
- appStateSubscription.remove();
823
- const totalWifiDuration = Date.now() - wifiConnectStartTime;
824
- console.log("[GallerySyncService] ========================================");
825
- console.log("[GallerySyncService] ✅ WIFI CONNECTION COMPLETE (already connected)");
826
- console.log("[GallerySyncService] ========================================");
827
- console.log(`[GallerySyncService] ⏱️ Total WiFi phase duration: ${totalWifiDuration}ms`);
828
- console.log(`[GallerySyncService] 🚀 Proceeding to file download from ${hotspotInfo.ip}:8089`);
829
- await this.startFileDownload(hotspotInfo);
830
- return; // Exit function successfully
831
- }
909
+ // Check current WiFi state before attempting connection. This is a shortcut, not
910
+ // a gate — when the SSID is unreadable we simply fall through to the connect call,
911
+ // which is itself a no-op if the phone is already on the target network.
912
+ if (!this.ssidReadable) {
913
+ console.log("[GallerySyncService] 📡 Skipping pre-connect SSID read (Location denied)");
832
914
  }
833
- catch (preError) {
834
- console.warn(`[GallerySyncService] ⚠️ Could not get current SSID: ${preError?.message}`);
835
- console.warn("[GallerySyncService] ⚠️ Error code:", preError?.code);
915
+ else {
916
+ try {
917
+ const preConnectSSID = await WifiManager.getCurrentWifiSSID();
918
+ console.log(`[GallerySyncService] 📡 Current WiFi SSID: "${preConnectSSID}"`);
919
+ // Check if already connected (shouldn't happen, but good to verify)
920
+ if (!localNetworkTransport.supportsScopedConnection() && preConnectSSID === hotspotInfo.ssid) {
921
+ console.log("[GallerySyncService] ✅ Already connected to target SSID! Proceeding to download.");
922
+ appStateSubscription.remove();
923
+ const totalWifiDuration = Date.now() - wifiConnectStartTime;
924
+ console.log("[GallerySyncService] ========================================");
925
+ console.log("[GallerySyncService] ✅ WIFI CONNECTION COMPLETE (already connected)");
926
+ console.log("[GallerySyncService] ========================================");
927
+ console.log(`[GallerySyncService] ⏱️ Total WiFi phase duration: ${totalWifiDuration}ms`);
928
+ console.log(`[GallerySyncService] 🚀 Proceeding to file download from ${hotspotInfo.ip}:8089`);
929
+ await this.startFileDownload(hotspotInfo);
930
+ return; // Exit function successfully
931
+ }
932
+ }
933
+ catch (preError) {
934
+ const message = preError instanceof Error ? preError.message : String(preError);
935
+ console.warn(`[GallerySyncService] ⚠️ Could not get current SSID: ${message}`);
936
+ console.warn("[GallerySyncService] ⚠️ Error code:", preError?.code);
937
+ // A permission wall here applies to every later read too — stop paying for it.
938
+ if (isSsidPermissionError(preError))
939
+ this.ssidReadable = false;
940
+ }
836
941
  }
837
942
  console.log(`[GallerySyncService] 🔌 Connecting the glasses-local transport...`);
838
943
  console.log(`[GallerySyncService] 🔌 Parameters:`);
@@ -851,47 +956,53 @@ class GallerySyncService {
851
956
  if (appBackgrounded && appBackgroundTime) {
852
957
  console.log(`[GallerySyncService] ⏱️ Time until backgrounding: ${appBackgroundTime - connectCallStartTime}ms`);
853
958
  }
854
- console.log(`[GallerySyncService] 📝 Note: On iOS, this does NOT guarantee actual connection!`);
855
- // iOS-specific: Verify actual WiFi connection by polling SSID
856
- // The library promise resolves when iOS ACCEPTS the request, not when connection completes
857
- if (Platform.OS === "ios") {
959
+ // NOTE: react-native-wifi-reborn >= 4.x already polls the SSID natively inside
960
+ // connectToProtectedSSID (up to 20 x 500ms) and only resolves once it observes the
961
+ // target network, so reaching this line is itself decent evidence of a real join.
962
+ // The poll below is a second opinion, not the primary gate — never fail on it
963
+ // when the SSID simply cannot be read.
964
+ if (Platform.OS === "ios" && !this.ssidReadable) {
965
+ console.log(`[GallerySyncService] 🍎 Skipping SSID verification (Location denied) - using glasses connectivity probe`);
966
+ }
967
+ else if (Platform.OS === "ios") {
858
968
  console.log(`[GallerySyncService] 🍎 iOS: Starting connection verification...`);
859
969
  console.log(`[GallerySyncService] 🍎 Will poll getCurrentWifiSSID() for up to 15 seconds`);
860
970
  const maxVerifyAttempts = 30; // 30 × 500ms = 15 seconds
861
- let connected = false;
862
- let lastSeenSSID = "unknown";
863
- for (let i = 0; i < maxVerifyAttempts; i++) {
971
+ const verification = await verifyIosHotspotSsid(hotspotInfo.ssid, async (pollNumber) => {
864
972
  try {
865
- const currentSSID = await WifiManager.getCurrentWifiSSID();
866
- lastSeenSSID = currentSSID || "null";
867
- console.log(`[GallerySyncService] 🍎 Verify poll ${i + 1}/${maxVerifyAttempts}: Current="${currentSSID}", Target="${hotspotInfo.ssid}"`);
868
- if (currentSSID === hotspotInfo.ssid) {
869
- console.log(`[GallerySyncService] 🍎 ✅ VERIFICATION SUCCESS! Connected to target network after ${(i + 1) * 500}ms`);
870
- connected = true;
871
- break;
872
- }
873
- else if (i === 0 && currentSSID === lastSeenSSID) {
874
- console.log(`[GallerySyncService] 🍎 ⚠️ Still on original network - iOS dialog may not have appeared yet`);
875
- }
973
+ const currentSsid = await WifiManager.getCurrentWifiSSID();
974
+ console.log(`[GallerySyncService] 🍎 Verify poll ${pollNumber}/${maxVerifyAttempts}: Current="${currentSsid}", Target="${hotspotInfo.ssid}"`);
975
+ return currentSsid;
876
976
  }
877
- catch (ssidError) {
878
- console.log(`[GallerySyncService] 🍎 ⚠️ Poll ${i + 1}: Could not check SSID: ${ssidError?.message}`);
879
- lastSeenSSID = "error";
977
+ catch (error) {
978
+ const message = error instanceof Error ? error.message : String(error);
979
+ console.log(`[GallerySyncService] 🍎 ⚠️ Poll ${pollNumber}: Could not check SSID: ${message}`);
980
+ throw error;
880
981
  }
881
- // Don't wait after last attempt
882
- if (i < maxVerifyAttempts - 1) {
883
- await new Promise((resolve) => BgTimer.setTimeout(() => resolve(), 500));
884
- }
885
- }
886
- if (!connected) {
982
+ }, () => new Promise((resolve) => BgTimer.setTimeout(() => resolve(), 500)), maxVerifyAttempts);
983
+ if (verification.status === "mismatched") {
887
984
  console.error(`[GallerySyncService] 🍎 ❌ VERIFICATION FAILED after 15 seconds`);
888
- console.error(`[GallerySyncService] 🍎 Last seen SSID: "${lastSeenSSID}"`);
985
+ console.error(`[GallerySyncService] 🍎 Last seen SSID: "${verification.lastSeenSsid}"`);
889
986
  console.error(`[GallerySyncService] 🍎 Expected SSID: "${hotspotInfo.ssid}"`);
890
987
  console.error(`[GallerySyncService] 🍎 Possible causes:`);
891
988
  console.error(`[GallerySyncService] 🍎 1. User did not tap "Join" on iOS WiFi dialog`);
892
- console.error(`[GallerySyncService] 🍎 2. iOS dialog did not appear (permission issue?)`);
989
+ console.error(`[GallerySyncService] 🍎 2. iOS dialog did not appear`);
893
990
  console.error(`[GallerySyncService] 🍎 3. iOS refused to switch networks`);
894
- throw new Error(`iOS WiFi verification failed - still on "${lastSeenSSID}", expected "${hotspotInfo.ssid}"`);
991
+ throw new Error(`iOS WiFi verification failed - still on "${verification.lastSeenSsid}", expected "${hotspotInfo.ssid}"`);
992
+ }
993
+ if (verification.status === "unavailable") {
994
+ console.warn(`[GallerySyncService] 🍎 SSID inspection unavailable; using glasses connectivity probe instead`);
995
+ // Latch ONLY on a real permission wall (revoked between pre-flight and now).
996
+ // A run of transient or empty reads must leave `ssidReadable` alone: killing
997
+ // it here would disable the readable-mismatch gate for the remaining retries
998
+ // and make a later failure blame Location while it is granted.
999
+ if (verification.permissionBlocked) {
1000
+ console.warn(`[GallerySyncService] 🍎 Location permission was revoked mid-sync - skipping SSID gates`);
1001
+ this.ssidReadable = false;
1002
+ }
1003
+ }
1004
+ else {
1005
+ console.log(`[GallerySyncService] 🍎 ✅ SSID verification succeeded`);
895
1006
  }
896
1007
  }
897
1008
  const attemptDuration = Date.now() - attemptStartTime;
@@ -901,25 +1012,36 @@ class GallerySyncService {
901
1012
  // Remove app state listener
902
1013
  appStateSubscription.remove();
903
1014
  console.log("[GallerySyncService] 👂 App state listener removed");
904
- // Final verification: Check SSID one more time before starting download
905
- try {
906
- const finalSSID = localNetworkTransport.isScopedConnectionActive()
907
- ? hotspotInfo.ssid
908
- : await WifiManager.getCurrentWifiSSID();
909
- console.log(`[GallerySyncService] 📶 Final SSID check before download: "${finalSSID}"`);
910
- if (Platform.OS === "android") {
911
- // Some local builds can have stale generated typings for the Bluetooth SDK module.
912
- ;
913
- BluetoothSdk.logCurrentWifiFrequency?.();
1015
+ // Final verification: Check SSID one more time before starting download.
1016
+ // Only the READ is allowed to fail softly. A readable SSID that does not match is
1017
+ // a real "we are about to download from the wrong network" signal and must abort —
1018
+ // previously the throw was inside the try, so its own catch swallowed it and the
1019
+ // gate never actually stopped anything.
1020
+ let finalSSID = null;
1021
+ if (!this.ssidReadable) {
1022
+ console.log("[GallerySyncService] 📶 Skipping final SSID check (Location denied)");
1023
+ }
1024
+ else {
1025
+ try {
1026
+ finalSSID = localNetworkTransport.isScopedConnectionActive()
1027
+ ? hotspotInfo.ssid
1028
+ : await WifiManager.getCurrentWifiSSID();
1029
+ console.log(`[GallerySyncService] 📶 Final SSID check before download: "${finalSSID}"`);
914
1030
  }
915
- if (finalSSID !== hotspotInfo.ssid) {
916
- console.error(`[GallerySyncService] SSID mismatch detected! Expected "${hotspotInfo.ssid}", got "${finalSSID}"`);
917
- throw new Error(`WiFi SSID mismatch - connected to "${finalSSID}" instead of "${hotspotInfo.ssid}"`);
1031
+ catch (finalError) {
1032
+ const message = finalError instanceof Error ? finalError.message : String(finalError);
1033
+ console.warn(`[GallerySyncService] ⚠️ Could not perform final SSID check: ${message}`);
1034
+ // Continue anyway - we've done our best to verify
918
1035
  }
919
1036
  }
920
- catch (finalError) {
921
- console.warn(`[GallerySyncService] ⚠️ Could not perform final SSID check: ${finalError?.message}`);
922
- // Continue anyway - we've done our best to verify
1037
+ if (Platform.OS === "android") {
1038
+ // Some local builds can have stale generated typings for the Bluetooth SDK module.
1039
+ ;
1040
+ BluetoothSdk.logCurrentWifiFrequency?.();
1041
+ }
1042
+ if (finalSSID && finalSSID !== hotspotInfo.ssid) {
1043
+ console.error(`[GallerySyncService] ❌ SSID mismatch detected! Expected "${hotspotInfo.ssid}", got "${finalSSID}"`);
1044
+ throw new Error(`WiFi SSID mismatch - connected to "${finalSSID}" instead of "${hotspotInfo.ssid}"`);
923
1045
  }
924
1046
  // iOS-specific: Wait for actual network connectivity to glasses
925
1047
  // Even though SSID is correct, iOS needs time for routing tables to update
@@ -930,17 +1052,20 @@ class GallerySyncService {
930
1052
  const maxProbeAttempts = 20; // 20 attempts × 500ms = 10 seconds max
931
1053
  let networkReady = false;
932
1054
  for (let probeNum = 1; probeNum <= maxProbeAttempts; probeNum++) {
1055
+ // Declared outside the try so the `finally` can always clear it — the fetch
1056
+ // rejects on most early probes, and an un-cleared BgTimer is a live native
1057
+ // timer (up to 20 per attempt, 100 per sync).
1058
+ const probeController = new AbortController();
1059
+ let probeTimeout = null;
933
1060
  try {
934
1061
  console.log(`[GallerySyncService] 🍎 Connectivity probe ${probeNum}/${maxProbeAttempts}...`);
935
1062
  // Try to reach the glasses health endpoint with a short timeout
936
- const probeController = new AbortController();
937
- const probeTimeout = BgTimer.setTimeout(() => probeController.abort(), 1000); // 1 second timeout per probe
1063
+ probeTimeout = BgTimer.setTimeout(() => probeController.abort(), 1000); // 1 second timeout per probe
938
1064
  const probeStartTime = Date.now();
939
1065
  const probeResponse = await localNetworkTransport.fetch(`http://${hotspotInfo.ip}:8089/api/health`, {
940
1066
  method: "GET",
941
1067
  signal: probeController.signal,
942
1068
  });
943
- BgTimer.clearTimeout(probeTimeout);
944
1069
  const probeDuration = Date.now() - probeStartTime;
945
1070
  console.log(`[GallerySyncService] 🍎 Probe ${probeNum} response: HTTP ${probeResponse.status} (${probeDuration}ms)`);
946
1071
  if (probeResponse.status === 200 || probeResponse.status === 404) {
@@ -952,10 +1077,14 @@ class GallerySyncService {
952
1077
  }
953
1078
  }
954
1079
  catch (probeError) {
955
- const errorMsg = probeError?.message || "unknown";
1080
+ const errorMsg = probeError instanceof Error ? probeError.message : String(probeError);
956
1081
  console.log(`[GallerySyncService] 🍎 Probe ${probeNum} failed: ${errorMsg.substring(0, 50)}${errorMsg.length > 50 ? "..." : ""}`);
957
1082
  // Continue to next probe
958
1083
  }
1084
+ finally {
1085
+ if (probeTimeout !== null)
1086
+ BgTimer.clearTimeout(probeTimeout);
1087
+ }
959
1088
  // Wait 500ms before next probe (unless this was the last attempt)
960
1089
  if (probeNum < maxProbeAttempts) {
961
1090
  await new Promise((resolve) => BgTimer.setTimeout(() => resolve(), 500));
@@ -1103,6 +1232,13 @@ class GallerySyncService {
1103
1232
  userErrorMessage =
1104
1233
  "Could not connect to glasses WiFi. Please ensure you accept the WiFi prompt when it appears.";
1105
1234
  }
1235
+ else if (!this.ssidReadable) {
1236
+ // We fell back to the connectivity probe because Location was denied, and the probe
1237
+ // did not reach the glasses either. Point the user at the actionable cause rather
1238
+ // than a raw "could not reach 192.168.43.1:8089".
1239
+ userErrorMessage = "Could not reach glasses over WiFi. Allow Location access so the app can verify the network.";
1240
+ emitGalleryNotice({ code: "location_permission_required" });
1241
+ }
1106
1242
  store.setSyncError(userErrorMessage);
1107
1243
  if (store.syncServiceOpenedHotspot) {
1108
1244
  await this.closeHotspot();