@mentra/engine 3.4.0-dev.990 → 3.4.0-dev.992

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 (39) hide show
  1. package/build/generated/releaseMetadata.js +5 -5
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  4. package/build/react/MentraLiveOtaFlow.js +1 -4
  5. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  6. package/build/react/useMentraLiveOta.js +2 -2
  7. package/build/react/useMentraLiveOta.js.map +1 -1
  8. package/build/services/OtaArtifactDownloader.d.ts +3 -1
  9. package/build/services/OtaArtifactDownloader.d.ts.map +1 -1
  10. package/build/services/OtaArtifactDownloader.js +57 -13
  11. package/build/services/OtaArtifactDownloader.js.map +1 -1
  12. package/build/services/OtaArtifactFailure.d.ts +18 -0
  13. package/build/services/OtaArtifactFailure.d.ts.map +1 -0
  14. package/build/services/OtaArtifactFailure.js +72 -0
  15. package/build/services/OtaArtifactFailure.js.map +1 -0
  16. package/build/services/OtaErrorMapping.d.ts +4 -2
  17. package/build/services/OtaErrorMapping.d.ts.map +1 -1
  18. package/build/services/OtaErrorMapping.js +57 -6
  19. package/build/services/OtaErrorMapping.js.map +1 -1
  20. package/build/services/OtaFailureReportService.d.ts +2 -0
  21. package/build/services/OtaFailureReportService.d.ts.map +1 -1
  22. package/build/services/OtaFailureReportService.js.map +1 -1
  23. package/build/services/OtaInstallCoordinator.d.ts +3 -1
  24. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  25. package/build/services/OtaInstallCoordinator.js +15 -26
  26. package/build/services/OtaInstallCoordinator.js.map +1 -1
  27. package/build/services/otaInstallPolicy.d.ts +2 -2
  28. package/build/services/otaInstallPolicy.js +2 -2
  29. package/build/services/otaInstallPolicy.js.map +1 -1
  30. package/package.json +8 -8
  31. package/src/generated/releaseMetadata.ts +5 -5
  32. package/src/react/MentraLiveOtaFlow.tsx +1 -6
  33. package/src/react/useMentraLiveOta.ts +2 -2
  34. package/src/services/OtaArtifactDownloader.ts +62 -15
  35. package/src/services/OtaArtifactFailure.ts +102 -0
  36. package/src/services/OtaErrorMapping.ts +60 -6
  37. package/src/services/OtaFailureReportService.ts +2 -0
  38. package/src/services/OtaInstallCoordinator.ts +17 -28
  39. package/src/services/otaInstallPolicy.ts +2 -2
@@ -87,8 +87,8 @@ export declare const OtaProgressMessages: {
87
87
  readonly globalTimeout: "Update took too long. Please try again.";
88
88
  readonly restartTimeout: "Glasses did not finish restarting. Restart them before trying the update again.";
89
89
  readonly sendOtaStartFailed: "Failed to communicate with glasses.";
90
- readonly hotspotArtifactDownloadFailed: "Could not download the update to your phone. Check phone internet and try again.";
91
- readonly hotspotArtifactVerifyFailed: "The update package could not be verified. Please try again later.";
90
+ readonly hotspotArtifactDownloadFailed: "The update could not be downloaded to your phone. Please try again.";
91
+ readonly hotspotArtifactVerifyFailed: "The update file could not be verified. Please try again later.";
92
92
  readonly hotspotWifiPermissionDenied: "Allow nearby devices access so the phone can connect to the glasses hotspot.";
93
93
  readonly hotspotStartFailed: "Could not start the glasses hotspot. Please try again.";
94
94
  readonly hotspotJoinFailed: "Could not connect the phone to the glasses hotspot. Please try again.";
@@ -111,8 +111,8 @@ export const OtaProgressMessages = {
111
111
  globalTimeout: "Update took too long. Please try again.",
112
112
  restartTimeout: "Glasses did not finish restarting. Restart them before trying the update again.",
113
113
  sendOtaStartFailed: "Failed to communicate with glasses.",
114
- hotspotArtifactDownloadFailed: "Could not download the update to your phone. Check phone internet and try again.",
115
- hotspotArtifactVerifyFailed: "The update package could not be verified. Please try again later.",
114
+ hotspotArtifactDownloadFailed: "The update could not be downloaded to your phone. Please try again.",
115
+ hotspotArtifactVerifyFailed: "The update file could not be verified. Please try again later.",
116
116
  hotspotWifiPermissionDenied: "Allow nearby devices access so the phone can connect to the glasses hotspot.",
117
117
  hotspotStartFailed: "Could not start the glasses hotspot. Please try again.",
118
118
  hotspotJoinFailed: "Could not connect the phone to the glasses hotspot. Please try again.",
@@ -1 +1 @@
1
- {"version":3,"file":"otaInstallPolicy.js","sourceRoot":"","sources":["../../src/services/otaInstallPolicy.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,EAAE,CAAA;AAE1C,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAA;AAC5B,sFAAsF;AACtF,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAA;AACrC,oEAAoE;AACpE,MAAM,CAAC,MAAM,mBAAmB,GAAG,OAAO,CAAA;AAC1C,kDAAkD;AAClD,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAM,CAAA;AAC/C,2DAA2D;AAC3D,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAA;AAC7C,mCAAmC;AACnC,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AACnD,+BAA+B;AAC/B,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAA;AACtC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAA;AAC1C,4FAA4F;AAC5F,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAA;AAC7C,2GAA2G;AAC3G,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAA;AAE7C,2EAA2E;AAC3E,EAAE;AACF,kFAAkF;AAClF,mFAAmF;AACnF,iFAAiF;AACjF,kFAAkF;AAClF,gFAAgF;AAChF,qEAAqE;AAErE,kFAAkF;AAClF,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAA;AAC7C,MAAM,CAAC,MAAM,wBAAwB,GAAG,iBAAiB,GAAG,uBAAuB,CAAA;AACnF,MAAM,CAAC,MAAM,0BAA0B,GAAG,mBAAmB,GAAG,uBAAuB,CAAA;AACvF,MAAM,CAAC,MAAM,gCAAgC,GAAG,yBAAyB,GAAG,uBAAuB,CAAA;AACnG,MAAM,CAAC,MAAM,6BAA6B,GAAG,sBAAsB,GAAG,uBAAuB,CAAA;AAC7F,MAAM,CAAC,MAAM,4BAA4B,GAAG,qBAAqB,GAAG,uBAAuB,CAAA;AAC3F,MAAM,CAAC,MAAM,uBAAuB,GAAG,gBAAgB,GAAG,uBAAuB,CAAA;AACjF,MAAM,CAAC,MAAM,8BAA8B,GAAG,uBAAuB,GAAG,uBAAuB,CAAA;AAC/F,MAAM,CAAC,MAAM,6BAA6B,GAAG,sBAAsB,GAAG,uBAAuB,CAAA;AAC7F;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,MAAM,GAAG,uBAAuB,CAAA;AAC/E;;;;;GAKG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,MAAM,GAAG,uBAAuB,CAAA;AAC1E,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,GAAG,uBAAuB,CAAA;AACtE,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAA;AACnD,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAA;AACnD,MAAM,CAAC,MAAM,4BAA4B,GAAG,EAAE,CAAA;AAC9C,MAAM,CAAC,MAAM,0BAA0B,GAAG,EAAE,CAAA;AAI5C;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CAAC,SAA2B;IACjE,OAAO,CAAC,CAAC,SAAS,IAAI,SAAS,CAAC,SAAS,KAAK,EAAE,IAAI,SAAS,CAAC,MAAM,KAAK,MAAM,CAAA;AACjF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CACtC,SAA2B,EAC3B,WAA+B,EAC/B,WAAsC;IAEtC,IAAI,SAAS;QAAE,OAAO,uBAAuB,CAAC,SAAS,CAAC,CAAA;IACxD,IAAI,WAAW;QAAE,OAAO,IAAI,CAAA;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,IAAI,EAAE,EAAE,EAAE,CAAC,CAAA;IACpD,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,wBAAwB,CAAA;AAChF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CACtC,SAA2B,EAC3B,WAA+B,EAC/B,WAAsC;IAEtC,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,WAAW,IAAI,EAAE,EAAE,EAAE,CAAC,CAAA;IACpD,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACxC,OAAO,KAAK,GAAG,wBAAwB,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAA;IAChE,CAAC;IACD,IAAI,SAAS;QAAE,OAAO,uBAAuB,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAA;IAC/E,IAAI,WAAW;QAAE,OAAO,QAAQ,CAAA;IAChC,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,cAAc,EAAE,4EAA4E;IAC5F,aAAa,EAAE,yCAAyC;IACxD,cAAc,EAAE,iFAAiF;IACjG,kBAAkB,EAAE,qCAAqC;IACzD,6BAA6B,EAAE,kFAAkF;IACjH,2BAA2B,EAAE,mEAAmE;IAChG,2BAA2B,EAAE,8EAA8E;IAC3G,kBAAkB,EAAE,wDAAwD;IAC5E,iBAAiB,EAAE,uEAAuE;IAC1F,mBAAmB,EAAE,uEAAuE;CACpF,CAAA","sourcesContent":["/** Shared OTA progress watchdog timings (see progress-legacy.tsx / progress.tsx). */\nimport type {OtaProgress, OtaStatus} from \"@mentra/bluetooth-sdk\"\n\n/**\n * First glasses `build_number` (ASG `versionCode`) that uses `progress.tsx` with unified\n * `ota_status` session UI. Strictly lower builds use `/ota/progress-legacy` (routed from\n * `check-for-updates.tsx` before navigation), and get the LEGACY_* padded policy below\n * in the unified OtaInstallCoordinator (WP 8C). Change only here.\n */\nexport const MINIMUM_OTA_STATUS_BUILD = 37\n\nexport const MAX_RETRIES = 3\n/** Wait between ended native ota_start requests; never overlaps a pending request. */\nexport const RETRY_INTERVAL_MS = 5000\n/** APK/BES and general install when progress events are expected */\nexport const PROGRESS_TIMEOUT_MS = 120_000\n/** Fail if still at 0% in starting/downloading */\nexport const DOWNLOAD_STUCK_TIMEOUT_MS = 70_000\n/** MTK system install often goes quiet for long periods */\nexport const MTK_INSTALL_TIMEOUT_MS = 300_000\n/** Whole multi-step session cap */\nexport const GLOBAL_OTA_TIMEOUT_MS = 20 * 60 * 1000\n/** BLE keepalive during OTA */\nexport const PING_INTERVAL_MS = 10_000\n/**\n * If we sent ota_query_status (e.g. on reconnect) and the glasses haven't\n * replied with an ota_status by then, fall back to ota_start. This recovers\n * from the case where the glasses process restarted between mount and\n * reconnect — the session is gone, ota_query_status returns nothing, and\n * the user would otherwise sit on the spinner forever.\n */\nexport const QUERY_REPLY_TIMEOUT_MS = 6000\n/** 15s lockout on the Continue button after a BES restart, to prevent an accidental tap. */\nexport const BES_CONTINUE_LOCKOUT_MS = 15_000\n/** Fail closed if a BES reboot never reconnects; normal restarts complete in a fraction of this window. */\nexport const BES_RESTART_TIMEOUT_MS = 120_000\n\n// --- Legacy old-build (< MINIMUM_OTA_STATUS_BUILD) OTA policy (WP 8C) ---\n//\n// Ported verbatim from mobile/src/app/ota/progress-legacy.tsx, which padded EVERY\n// watchdog/timer by +20s for old ASG builds (\"Legacy OTA: +20s on every watchdog /\n// timer duration\"). Old builds respond slower on every path, so where the legacy\n// screen used a longer duration the coordinator applies the longer value whenever\n// the session is legacy-shaped (see isLegacyShapedOtaStatus / the coordinator's\n// session-shape check). Builds >= 37 keep the unpadded values above.\n\n/** The uniform pad progress-legacy.tsx added to every duration for old builds. */\nexport const LEGACY_EXTRA_TIMEOUT_MS = 20_000\nexport const LEGACY_RETRY_INTERVAL_MS = RETRY_INTERVAL_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_PROGRESS_TIMEOUT_MS = PROGRESS_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_DOWNLOAD_STUCK_TIMEOUT_MS = DOWNLOAD_STUCK_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_INSTALL_TIMEOUT_MS = MTK_INSTALL_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_GLOBAL_OTA_TIMEOUT_MS = GLOBAL_OTA_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_PING_INTERVAL_MS = PING_INTERVAL_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_BES_CONTINUE_LOCKOUT_MS = BES_CONTINUE_LOCKOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_BES_RESTART_TIMEOUT_MS = BES_RESTART_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\n/**\n * After a legacy apk `install FINISHED` observed in-flight, the legacy screen held the\n * completed state for 12s (+20s pad) so the ASG process restart settles (and fresh\n * version_info lands) before the user can continue.\n */\nexport const LEGACY_APK_COMPLETION_SETTLE_MS = 12_000 + LEGACY_EXTRA_TIMEOUT_MS\n/**\n * Legacy MTK install stall simulation (display-only): the MTK system install typically\n * stalls around 49-50%. After LEGACY_MTK_STALL_DETECT_MS without a new event in the\n * [45, 55) zone, the legacy screen simulated +1% every LEGACY_MTK_SIM_TICK_MS starting\n * at max(stall+1, 51) and capped at 60%, purely to keep the user informed.\n */\nexport const LEGACY_MTK_STALL_DETECT_MS = 20_000 + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_SIM_TICK_MS = 15_000 + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_STALL_ZONE_MIN_PERCENT = 45\nexport const LEGACY_MTK_STALL_ZONE_MAX_PERCENT = 55\nexport const LEGACY_MTK_SIM_FLOOR_PERCENT = 51\nexport const LEGACY_MTK_SIM_CAP_PERCENT = 60\n\nexport type OtaProtocolProfile = \"legacy\" | \"unified\"\n\n/**\n * Legacy-shaped ota_status: old (< 37) ASG builds send `ota_progress`, which the SDK\n * (Android MentraLive.kt / iOS MentraLive.swift) maps to a unified ota_status with\n * sessionId \"\" and totalSteps 1, and FINISHED→\"complete\" regardless of phase. A real\n * (>= 37) session always carries a sessionId; an \"idle\" reply also has sessionId \"\" but\n * proves the glasses speak the unified protocol, so it is NOT legacy-shaped.\n */\nexport function isLegacyShapedOtaStatus(otaStatus: OtaStatus | null): boolean {\n return !!otaStatus && otaStatus.sessionId === \"\" && otaStatus.status !== \"idle\"\n}\n\n/**\n * Is the current install session legacy-shaped? Event shape wins when events exist\n * (per the WP 8C rule: key off legacy-shaped inputs, not a build flag, where possible);\n * before any event arrives only the glasses build number can tell.\n */\nexport function isLegacyShapedOtaSession(\n otaStatus: OtaStatus | null,\n otaProgress: OtaProgress | null,\n buildNumber: string | null | undefined,\n): boolean {\n if (otaStatus) return isLegacyShapedOtaStatus(otaStatus)\n if (otaProgress) return true\n const build = Number.parseInt(buildNumber ?? \"\", 10)\n return Number.isFinite(build) && build > 0 && build < MINIMUM_OTA_STATUS_BUILD\n}\n\n/**\n * Select the compatibility profile once at install-screen attach. The old split\n * routes made this decision from the pre-update build and kept it for the whole\n * mounted flow, even when an APK step restarted into a newer ASG build. Returning\n * null leaves the coordinator undecided until the first meaningful OTA event.\n */\nexport function selectOtaProtocolProfile(\n otaStatus: OtaStatus | null,\n otaProgress: OtaProgress | null,\n buildNumber: string | null | undefined,\n): OtaProtocolProfile | null {\n const build = Number.parseInt(buildNumber ?? \"\", 10)\n if (Number.isFinite(build) && build > 0) {\n return build < MINIMUM_OTA_STATUS_BUILD ? \"legacy\" : \"unified\"\n }\n if (otaStatus) return isLegacyShapedOtaStatus(otaStatus) ? \"legacy\" : \"unified\"\n if (otaProgress) return \"legacy\"\n return null\n}\n\nexport const OtaProgressMessages = {\n stalledOrStuck: \"Update may have failed. Ensure glasses have internet access and try again.\",\n globalTimeout: \"Update took too long. Please try again.\",\n restartTimeout: \"Glasses did not finish restarting. Restart them before trying the update again.\",\n sendOtaStartFailed: \"Failed to communicate with glasses.\",\n hotspotArtifactDownloadFailed: \"Could not download the update to your phone. Check phone internet and try again.\",\n hotspotArtifactVerifyFailed: \"The update package could not be verified. Please try again later.\",\n hotspotWifiPermissionDenied: \"Allow nearby devices access so the phone can connect to the glasses hotspot.\",\n hotspotStartFailed: \"Could not start the glasses hotspot. Please try again.\",\n hotspotJoinFailed: \"Could not connect the phone to the glasses hotspot. Please try again.\",\n hotspotServerFailed: \"Could not make the update available to the glasses. Please try again.\",\n} as const\n"]}
1
+ {"version":3,"file":"otaInstallPolicy.js","sourceRoot":"","sources":["../../src/services/otaInstallPolicy.ts"],"names":[],"mappings":"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","sourcesContent":["/** Shared OTA progress watchdog timings (see progress-legacy.tsx / progress.tsx). */\nimport type {OtaProgress, OtaStatus} from \"@mentra/bluetooth-sdk\"\n\n/**\n * First glasses `build_number` (ASG `versionCode`) that uses `progress.tsx` with unified\n * `ota_status` session UI. Strictly lower builds use `/ota/progress-legacy` (routed from\n * `check-for-updates.tsx` before navigation), and get the LEGACY_* padded policy below\n * in the unified OtaInstallCoordinator (WP 8C). Change only here.\n */\nexport const MINIMUM_OTA_STATUS_BUILD = 37\n\nexport const MAX_RETRIES = 3\n/** Wait between ended native ota_start requests; never overlaps a pending request. */\nexport const RETRY_INTERVAL_MS = 5000\n/** APK/BES and general install when progress events are expected */\nexport const PROGRESS_TIMEOUT_MS = 120_000\n/** Fail if still at 0% in starting/downloading */\nexport const DOWNLOAD_STUCK_TIMEOUT_MS = 70_000\n/** MTK system install often goes quiet for long periods */\nexport const MTK_INSTALL_TIMEOUT_MS = 300_000\n/** Whole multi-step session cap */\nexport const GLOBAL_OTA_TIMEOUT_MS = 20 * 60 * 1000\n/** BLE keepalive during OTA */\nexport const PING_INTERVAL_MS = 10_000\n/**\n * If we sent ota_query_status (e.g. on reconnect) and the glasses haven't\n * replied with an ota_status by then, fall back to ota_start. This recovers\n * from the case where the glasses process restarted between mount and\n * reconnect — the session is gone, ota_query_status returns nothing, and\n * the user would otherwise sit on the spinner forever.\n */\nexport const QUERY_REPLY_TIMEOUT_MS = 6000\n/** 15s lockout on the Continue button after a BES restart, to prevent an accidental tap. */\nexport const BES_CONTINUE_LOCKOUT_MS = 15_000\n/** Fail closed if a BES reboot never reconnects; normal restarts complete in a fraction of this window. */\nexport const BES_RESTART_TIMEOUT_MS = 120_000\n\n// --- Legacy old-build (< MINIMUM_OTA_STATUS_BUILD) OTA policy (WP 8C) ---\n//\n// Ported verbatim from mobile/src/app/ota/progress-legacy.tsx, which padded EVERY\n// watchdog/timer by +20s for old ASG builds (\"Legacy OTA: +20s on every watchdog /\n// timer duration\"). Old builds respond slower on every path, so where the legacy\n// screen used a longer duration the coordinator applies the longer value whenever\n// the session is legacy-shaped (see isLegacyShapedOtaStatus / the coordinator's\n// session-shape check). Builds >= 37 keep the unpadded values above.\n\n/** The uniform pad progress-legacy.tsx added to every duration for old builds. */\nexport const LEGACY_EXTRA_TIMEOUT_MS = 20_000\nexport const LEGACY_RETRY_INTERVAL_MS = RETRY_INTERVAL_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_PROGRESS_TIMEOUT_MS = PROGRESS_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_DOWNLOAD_STUCK_TIMEOUT_MS = DOWNLOAD_STUCK_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_INSTALL_TIMEOUT_MS = MTK_INSTALL_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_GLOBAL_OTA_TIMEOUT_MS = GLOBAL_OTA_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_PING_INTERVAL_MS = PING_INTERVAL_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_BES_CONTINUE_LOCKOUT_MS = BES_CONTINUE_LOCKOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_BES_RESTART_TIMEOUT_MS = BES_RESTART_TIMEOUT_MS + LEGACY_EXTRA_TIMEOUT_MS\n/**\n * After a legacy apk `install FINISHED` observed in-flight, the legacy screen held the\n * completed state for 12s (+20s pad) so the ASG process restart settles (and fresh\n * version_info lands) before the user can continue.\n */\nexport const LEGACY_APK_COMPLETION_SETTLE_MS = 12_000 + LEGACY_EXTRA_TIMEOUT_MS\n/**\n * Legacy MTK install stall simulation (display-only): the MTK system install typically\n * stalls around 49-50%. After LEGACY_MTK_STALL_DETECT_MS without a new event in the\n * [45, 55) zone, the legacy screen simulated +1% every LEGACY_MTK_SIM_TICK_MS starting\n * at max(stall+1, 51) and capped at 60%, purely to keep the user informed.\n */\nexport const LEGACY_MTK_STALL_DETECT_MS = 20_000 + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_SIM_TICK_MS = 15_000 + LEGACY_EXTRA_TIMEOUT_MS\nexport const LEGACY_MTK_STALL_ZONE_MIN_PERCENT = 45\nexport const LEGACY_MTK_STALL_ZONE_MAX_PERCENT = 55\nexport const LEGACY_MTK_SIM_FLOOR_PERCENT = 51\nexport const LEGACY_MTK_SIM_CAP_PERCENT = 60\n\nexport type OtaProtocolProfile = \"legacy\" | \"unified\"\n\n/**\n * Legacy-shaped ota_status: old (< 37) ASG builds send `ota_progress`, which the SDK\n * (Android MentraLive.kt / iOS MentraLive.swift) maps to a unified ota_status with\n * sessionId \"\" and totalSteps 1, and FINISHED→\"complete\" regardless of phase. A real\n * (>= 37) session always carries a sessionId; an \"idle\" reply also has sessionId \"\" but\n * proves the glasses speak the unified protocol, so it is NOT legacy-shaped.\n */\nexport function isLegacyShapedOtaStatus(otaStatus: OtaStatus | null): boolean {\n return !!otaStatus && otaStatus.sessionId === \"\" && otaStatus.status !== \"idle\"\n}\n\n/**\n * Is the current install session legacy-shaped? Event shape wins when events exist\n * (per the WP 8C rule: key off legacy-shaped inputs, not a build flag, where possible);\n * before any event arrives only the glasses build number can tell.\n */\nexport function isLegacyShapedOtaSession(\n otaStatus: OtaStatus | null,\n otaProgress: OtaProgress | null,\n buildNumber: string | null | undefined,\n): boolean {\n if (otaStatus) return isLegacyShapedOtaStatus(otaStatus)\n if (otaProgress) return true\n const build = Number.parseInt(buildNumber ?? \"\", 10)\n return Number.isFinite(build) && build > 0 && build < MINIMUM_OTA_STATUS_BUILD\n}\n\n/**\n * Select the compatibility profile once at install-screen attach. The old split\n * routes made this decision from the pre-update build and kept it for the whole\n * mounted flow, even when an APK step restarted into a newer ASG build. Returning\n * null leaves the coordinator undecided until the first meaningful OTA event.\n */\nexport function selectOtaProtocolProfile(\n otaStatus: OtaStatus | null,\n otaProgress: OtaProgress | null,\n buildNumber: string | null | undefined,\n): OtaProtocolProfile | null {\n const build = Number.parseInt(buildNumber ?? \"\", 10)\n if (Number.isFinite(build) && build > 0) {\n return build < MINIMUM_OTA_STATUS_BUILD ? \"legacy\" : \"unified\"\n }\n if (otaStatus) return isLegacyShapedOtaStatus(otaStatus) ? \"legacy\" : \"unified\"\n if (otaProgress) return \"legacy\"\n return null\n}\n\nexport const OtaProgressMessages = {\n stalledOrStuck: \"Update may have failed. Ensure glasses have internet access and try again.\",\n globalTimeout: \"Update took too long. Please try again.\",\n restartTimeout: \"Glasses did not finish restarting. Restart them before trying the update again.\",\n sendOtaStartFailed: \"Failed to communicate with glasses.\",\n hotspotArtifactDownloadFailed: \"The update could not be downloaded to your phone. Please try again.\",\n hotspotArtifactVerifyFailed: \"The update file could not be verified. Please try again later.\",\n hotspotWifiPermissionDenied: \"Allow nearby devices access so the phone can connect to the glasses hotspot.\",\n hotspotStartFailed: \"Could not start the glasses hotspot. Please try again.\",\n hotspotJoinFailed: \"Could not connect the phone to the glasses hotspot. Please try again.\",\n hotspotServerFailed: \"Could not make the update available to the glasses. Please try again.\",\n} as const\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/engine",
3
- "version": "3.4.0-dev.990",
3
+ "version": "3.4.0-dev.992",
4
4
  "description": "Mentra Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,13 +97,13 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@mentra/acs-meeting": "3.4.0-dev.990",
101
- "@mentra/glasses-media": "3.4.0-dev.990",
102
- "@mentra/bluetooth-sdk": "3.4.0-dev.990",
103
- "@mentra/cloud-client": "3.4.0-dev.990",
104
- "@mentra/cloud-protocol": "3.4.0-dev.990",
105
- "@mentra/crust": "3.4.0-dev.990",
106
- "@mentra/miniapp": "3.4.0-dev.990",
100
+ "@mentra/acs-meeting": "3.4.0-dev.992",
101
+ "@mentra/glasses-media": "3.4.0-dev.992",
102
+ "@mentra/bluetooth-sdk": "3.4.0-dev.992",
103
+ "@mentra/cloud-client": "3.4.0-dev.992",
104
+ "@mentra/cloud-protocol": "3.4.0-dev.992",
105
+ "@mentra/crust": "3.4.0-dev.992",
106
+ "@mentra/miniapp": "3.4.0-dev.992",
107
107
  "buffer": "^6.0.3",
108
108
  "events": "^3.3.0",
109
109
  "jszip": "^3.10.1",
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.4.0",
15
- "releaseIdentity": "3.4.0-dev.990",
16
- "releaseSetId": "mentra-3.4.0-dev.990",
17
- "sourceCommit": "1e8a712475c14cc287dff714d68128601d82799c",
18
- "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.990.json",
19
- "otaManifestSha256": "b024179ae3dff36b60de1e24033be243dae000b227ecd209b863609e68f45a83"
15
+ "releaseIdentity": "3.4.0-dev.992",
16
+ "releaseSetId": "mentra-3.4.0-dev.992",
17
+ "sourceCommit": "28f0eee5d65ba53303f6ce806ce5c52fff5395e3",
18
+ "otaManifestUrl": "https://artifactscdn.mentraglass.com/Mentra-Community/MentraOS/releases/mentra-builds-v3.4.0/mentra-live-ota-3.4.0-dev.992.json",
19
+ "otaManifestSha256": "958b967a2531b2b91b19d205982a061306dfdf59c682dc68e3e32fc8c4a5d81b"
20
20
  })
@@ -618,11 +618,7 @@ function OtaFlowContent({
618
618
  icon="alert"
619
619
  title={translate("ota:updateFailed")}>
620
620
  <BodyText colors={colors}>{failureMessage(state.error, translate)}</BodyText>
621
- {state.error?.glassesCode ? (
622
- <Text style={[styles.errorCode, {color: colors.textDim}]} testID="ota-error-code">
623
- {translate("ota:errorCode", {code: state.error.glassesCode})}
624
- </Text>
625
- ) : null}
621
+
626
622
  </FlowPage>
627
623
  )
628
624
  }
@@ -898,7 +894,6 @@ const styles = StyleSheet.create({
898
894
  svgIcon: {alignItems: "center", height: 72, justifyContent: "center", width: 72},
899
895
  title: {fontSize: 20, fontWeight: "600", textAlign: "center"},
900
896
  body: {fontSize: 14, lineHeight: 20, maxWidth: 420, textAlign: "center"},
901
- errorCode: {fontSize: 12, fontVariant: ["tabular-nums"], lineHeight: 16, opacity: 0.7, textAlign: "center"},
902
897
  percent: {fontSize: 30, fontVariant: ["tabular-nums"], fontWeight: "700"},
903
898
  progressTrack: {borderRadius: 4, height: 8, maxWidth: 420, overflow: "hidden", width: "100%"},
904
899
  progressFill: {borderRadius: 4, height: 8},
@@ -838,7 +838,7 @@ export function useMentraLiveOta(options: UseMentraLiveOtaOptions = {}): MentraL
838
838
  // phone-side copy outranks it. Only a failed ota_status carries a current code.
839
839
  const glassesCode = installSnapshot.otaStatus?.status === "failed" ? installSnapshot.otaStatus.error || null : null
840
840
  // Precedence mirrors the legacy screen: the BES restart instruction, then a phone-side
841
- // watchdog/preflight message (English-only, no copy key), then the glasses code mapped to
841
+ // watchdog/preflight message (translated when a copy key is available), then the glasses code mapped to
842
842
  // copy (unknown codes get the generic glasses-error copy rather than the raw code).
843
843
  let displayedError: string
844
844
  let copyKey: string | null
@@ -847,7 +847,7 @@ export function useMentraLiveOta(options: UseMentraLiveOtaOptions = {}): MentraL
847
847
  copyKey = OTA_ERROR_BES_RESTART_REQUIRED_COPY_KEY
848
848
  } else if (installSnapshot.errorMsg) {
849
849
  displayedError = installSnapshot.errorMsg
850
- copyKey = null
850
+ copyKey = installSnapshot.errorCopyKey ?? null
851
851
  } else {
852
852
  displayedError = getOtaErrorMessage(glassesCode)
853
853
  copyKey = otaErrorCopyKey(glassesCode)
@@ -1,4 +1,5 @@
1
1
  import * as RNFS from "@dr.pogodin/react-native-fs"
2
+ import {otaArtifactFailure, type OtaArtifactFailure} from "./OtaArtifactFailure"
2
3
  import type {OtaCheckCurrentGlassesResult, VersionJson} from "./OtaUpdateCheckService"
3
4
 
4
5
  /**
@@ -45,12 +46,33 @@ export class OtaArtifactError extends Error {
45
46
  constructor(
46
47
  public readonly code: "artifact_download_failed" | "artifact_verify_failed" | "manifest_invalid",
47
48
  message: string,
49
+ public readonly failure?: OtaArtifactFailure,
50
+ cause?: unknown,
48
51
  ) {
49
52
  super(message)
50
53
  this.name = "OtaArtifactError"
54
+ // Native Errors may contain URLs and paths. Keep the original available locally,
55
+ // but only the bounded, redacted failure snapshot can enter JSON/incident evidence.
56
+ if (cause !== undefined) Object.defineProperty(this, "cause", {value: cause})
51
57
  }
52
58
  }
53
59
 
60
+ function artifactPreparationError(
61
+ error: unknown,
62
+ context: Omit<OtaArtifactFailure, "category" | "nativeErrors">,
63
+ ): OtaArtifactError {
64
+ return new OtaArtifactError(
65
+ error instanceof OtaArtifactError
66
+ ? error.code
67
+ : context.stage === "verify"
68
+ ? "artifact_verify_failed"
69
+ : "artifact_download_failed",
70
+ error instanceof OtaArtifactError ? error.message : "Preparing the update artifact failed",
71
+ otaArtifactFailure(error, context),
72
+ error,
73
+ )
74
+ }
75
+
54
76
  const ASG_PACKAGE = "com.mentra.asg_client"
55
77
 
56
78
  export function otaArtifactsDirectory(): string {
@@ -135,7 +157,16 @@ export async function prepareArtifacts(
135
157
  ) => Promise<{statusCode: number}>,
136
158
  ): Promise<PreparedOtaArtifact[]> {
137
159
  const directory = otaArtifactsDirectory()
138
- await RNFS.mkdir(directory, {NSURLIsExcludedFromBackupKey: true})
160
+ try {
161
+ await RNFS.mkdir(directory, {NSURLIsExcludedFromBackupKey: true})
162
+ } catch (error) {
163
+ throw artifactPreparationError(error, {
164
+ artifact: null,
165
+ stage: "save",
166
+ bytesWritten: 0,
167
+ contentLength: 0,
168
+ })
169
+ }
139
170
 
140
171
  const prepared: PreparedOtaArtifact[] = []
141
172
  for (let index = 0; index < plan.length; index++) {
@@ -152,19 +183,34 @@ export async function prepareArtifacts(
152
183
  })
153
184
 
154
185
  const cachedPath = `${directory}/${entry.sha256}`
155
- if (await RNFS.exists(cachedPath)) {
156
- const cachedHash = (await RNFS.hash(cachedPath, "sha256")).toLowerCase()
157
- if (cachedHash === entry.sha256) {
158
- prepared.push({...entry, filePath: cachedPath})
159
- continue
186
+ try {
187
+ if (await RNFS.exists(cachedPath)) {
188
+ const cachedHash = (await RNFS.hash(cachedPath, "sha256")).toLowerCase()
189
+ if (cachedHash === entry.sha256) {
190
+ prepared.push({...entry, filePath: cachedPath})
191
+ continue
192
+ }
193
+ await safeUnlink(cachedPath)
160
194
  }
161
- await safeUnlink(cachedPath)
195
+ } catch (error) {
196
+ throw artifactPreparationError(error, {
197
+ artifact: entry.kind,
198
+ stage: "verify",
199
+ bytesWritten: 0,
200
+ contentLength: 0,
201
+ })
162
202
  }
163
203
 
164
204
  const partPath = `${directory}/download-${index}.part`
165
205
  await safeUnlink(partPath)
206
+ let stage: OtaArtifactFailure["stage"] = "download"
207
+ let bytesWritten = 0
208
+ let contentLength = 0
209
+ let httpStatus: number | undefined
166
210
  try {
167
- const reportProgress = (bytesWritten: number, contentLength: number) => {
211
+ const reportProgress = (written: number, length: number) => {
212
+ bytesWritten = written
213
+ contentLength = length
168
214
  onProgress?.({
169
215
  kind: entry.kind,
170
216
  index,
@@ -182,10 +228,15 @@ export async function prepareArtifacts(
182
228
  connectionTimeout: 30_000,
183
229
  readTimeout: 30_000,
184
230
  progressDivider: 5,
231
+ begin: (res) => {
232
+ httpStatus = res.statusCode
233
+ contentLength = res.contentLength
234
+ },
185
235
  progress: (res: RNFS.DownloadProgressCallbackResultT) => {
186
236
  reportProgress(res.bytesWritten, res.contentLength)
187
237
  },
188
238
  }).promise
239
+ httpStatus = downloadResult.statusCode
189
240
  // A resumed native download can finish with 206. Accept it only through
190
241
  // the same full-file manifest SHA256 verification below.
191
242
  if (downloadResult.statusCode !== 200 && downloadResult.statusCode !== 206) {
@@ -195,6 +246,7 @@ export async function prepareArtifacts(
195
246
  )
196
247
  }
197
248
 
249
+ stage = "verify"
198
250
  const actualSha256 = (await RNFS.hash(partPath, "sha256")).toLowerCase()
199
251
  if (actualSha256 !== entry.sha256) {
200
252
  throw new OtaArtifactError(
@@ -203,19 +255,14 @@ export async function prepareArtifacts(
203
255
  )
204
256
  }
205
257
 
258
+ stage = "save"
206
259
  const finalPath = `${directory}/${entry.sha256}`
207
260
  await safeUnlink(finalPath)
208
261
  await RNFS.moveFile(partPath, finalPath)
209
262
  prepared.push({...entry, filePath: finalPath})
210
263
  } catch (error) {
211
264
  await safeUnlink(partPath)
212
- if (error instanceof OtaArtifactError) {
213
- throw error
214
- }
215
- throw new OtaArtifactError(
216
- "artifact_download_failed",
217
- `Download of ${entry.kind} artifact failed: ${error instanceof Error ? error.message : String(error)}`,
218
- )
265
+ throw artifactPreparationError(error, {artifact: entry.kind, stage, httpStatus, bytesWritten, contentLength})
219
266
  }
220
267
  }
221
268
  return prepared
@@ -0,0 +1,102 @@
1
+ export type OtaArtifactFailureCategory =
2
+ | "internet"
3
+ | "connection"
4
+ | "server"
5
+ | "timeout"
6
+ | "storage"
7
+ | "save"
8
+ | "secure_connection"
9
+ | "download"
10
+ | "verify"
11
+
12
+ export interface OtaArtifactFailure {
13
+ artifact: "apk" | "mtk" | "bes" | null
14
+ stage: "download" | "verify" | "save"
15
+ category: OtaArtifactFailureCategory
16
+ httpStatus?: number
17
+ bytesWritten: number
18
+ contentLength: number
19
+ nativeErrors: {name?: string; code?: string; domain?: string; message?: string}[]
20
+ }
21
+
22
+ /** Never retain artifact URLs, local paths, headers, or arbitrary native userInfo. */
23
+ function privateErrorText(value: unknown): string | undefined {
24
+ if (typeof value !== "string" && typeof value !== "number") return undefined
25
+ return String(value)
26
+ .replace(/(?:https?|file):\/\/[^\s"'<>]+/gi, "[REDACTED_URL]")
27
+ .replace(/\/(?:data|var|private|storage|Users)\/[^\s"'<>]*/g, "[REDACTED_PATH]")
28
+ .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=][^\r\n]*/gi, "[REDACTED_HEADER]")
29
+ .replace(
30
+ /\b(?:bearer\s+\S+|(?:token|password|signature|api[_-]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+))/gi,
31
+ "[REDACTED]",
32
+ )
33
+ .slice(0, 500)
34
+ }
35
+
36
+ /** Preserve Error's non-enumerable fields before JSON serialization drops them. */
37
+ export function otaArtifactFailure(
38
+ error: unknown,
39
+ context: Omit<OtaArtifactFailure, "category" | "nativeErrors">,
40
+ ): OtaArtifactFailure {
41
+ const nativeErrors: OtaArtifactFailure["nativeErrors"] = []
42
+ const seen = new Set<unknown>()
43
+ let current = error
44
+ for (let depth = 0; current != null && depth < 4 && !seen.has(current); depth++) {
45
+ seen.add(current)
46
+ if (typeof current !== "object") {
47
+ nativeErrors.push({message: privateErrorText(current)})
48
+ break
49
+ }
50
+ const detail = current as Record<string, unknown>
51
+ nativeErrors.push({
52
+ name: privateErrorText(detail.name),
53
+ code: privateErrorText(detail.code),
54
+ domain: privateErrorText(detail.domain),
55
+ message: privateErrorText(detail.message),
56
+ })
57
+ current = detail.cause
58
+ }
59
+ const text = nativeErrors.map((entry) => Object.values(entry).join(" ")).join(" ")
60
+ let httpStatus = context.httpStatus
61
+ if (context.stage === "download" && httpStatus === undefined) {
62
+ // BackgroundOtaArtifactDownloader.swift rejects non-2xx responses instead of
63
+ // resolving a status. Expo can wrap this exact producer message in another Error.
64
+ for (const entry of nativeErrors) {
65
+ const response = /\bArtifact download failed with HTTP ([1-5]\d{2})\b/.exec(entry.message ?? "")
66
+ if (response) {
67
+ httpStatus = Number(response[1])
68
+ break
69
+ }
70
+ }
71
+ }
72
+ // A DNS error, timeout, reset, or HTTP failure alone does not establish no internet.
73
+ let category: OtaArtifactFailureCategory = context.stage === "verify" ? "verify" : "download"
74
+ if (/\bENOSPC\b|No space left on device/i.test(text)) category = "storage"
75
+ else if (context.stage === "save") category = "save"
76
+ else if (context.stage === "download") {
77
+ if (/\b(?:EACCES|EROFS)\b|Permission denied|Read-only file system/i.test(text)) category = "save"
78
+ else if (
79
+ httpStatus !== undefined &&
80
+ httpStatus >= 100 &&
81
+ httpStatus <= 599 &&
82
+ httpStatus !== 200 &&
83
+ httpStatus !== 206
84
+ )
85
+ category = "server"
86
+ else if (
87
+ nativeErrors.some((entry) => entry.domain === "NSURLErrorDomain" && entry.code === "-1009") ||
88
+ /\b(?:ENETDOWN|NSURLErrorNotConnectedToInternet)\b|Network is down|not connected to the Internet/i.test(text)
89
+ )
90
+ category = "internet"
91
+ else if (/\b(?:ETIMEDOUT|SocketTimeoutException)\b|timed? out/i.test(text)) category = "timeout"
92
+ else if (/\b(?:SSLHandshakeException|SSLPeerUnverifiedException|CERTIFICATE_VERIFY_FAILED)\b/i.test(text))
93
+ category = "secure_connection"
94
+ else if (
95
+ /\b(?:ENETUNREACH|ECONNRESET|ECONNABORTED|EPIPE|UnknownHostException|EAI_NODATA|EAI_AGAIN)\b|Software caused connection abort|Connection reset|Unable to resolve host/i.test(
96
+ text,
97
+ )
98
+ )
99
+ category = "connection"
100
+ }
101
+ return {...context, httpStatus, category, nativeErrors}
102
+ }
@@ -8,8 +8,8 @@ import type {OtaProgress, OtaStatus} from "../facades/ota"
8
8
  * The glasses-side producers are `sendProgressToPhone(..., "FAILED", errorCode)` in the
9
9
  * ASG client's OtaHelper (download classification, install failures, and the downgrade
10
10
  * handoff to the recovery worker). Keep this table in sync with those codes; anything
11
- * else falls back to `OTA_ERROR_UNKNOWN_GLASSES_COPY_KEY` and the raw code is shown in a
12
- * secondary line so support can still identify it.
11
+ * else falls back to `OTA_ERROR_UNKNOWN_GLASSES_COPY_KEY`. Raw codes remain in private
12
+ * incident evidence rather than the product failure page.
13
13
  */
14
14
  export const OTA_GLASSES_ERROR_COPY_KEYS: Readonly<Record<string, string>> = {
15
15
  dns_failed: "ota:errorDnsFailed",
@@ -42,6 +42,45 @@ export const OTA_ERROR_UNKNOWN_GLASSES_COPY_KEY = "ota:errorGlassesUnknown"
42
42
  export const OTA_ERROR_GENERIC_COPY_KEY = "ota:errorGeneric"
43
43
  export const OTA_ERROR_BES_RESTART_REQUIRED_COPY_KEY = "ota:errorBesRestartRequired"
44
44
 
45
+ const PHONE_ARTIFACT_COPY_KEYS: Readonly<Record<string, string>> = {
46
+ internet: "ota:errorPhoneInternet",
47
+ connection: "ota:errorPhoneConnection",
48
+ server: "ota:errorPhoneDownloadServer",
49
+ timeout: "ota:errorPhoneDownloadTimeout",
50
+ storage: "ota:errorPhoneStorage",
51
+ save: "ota:errorPhoneSave",
52
+ secure_connection: "ota:errorPhoneSecureConnection",
53
+ download: "ota:errorPhoneDownload",
54
+ verify: "ota:errorPhoneVerify",
55
+ }
56
+
57
+ /** A known failed stage is useful; it is not evidence of an internet outage. */
58
+ export function phoneOtaErrorCopyKey(error: unknown): string {
59
+ if (typeof error !== "object" || error === null) return OTA_ERROR_GENERIC_COPY_KEY
60
+ const detail = error as {code?: unknown; failure?: {category?: unknown}}
61
+ switch (detail.code) {
62
+ case "artifact_download_failed":
63
+ case "artifact_verify_failed": {
64
+ const category = detail.failure?.category
65
+ if (typeof category === "string" && hasOwn(PHONE_ARTIFACT_COPY_KEYS, category))
66
+ return PHONE_ARTIFACT_COPY_KEYS[category]
67
+ return detail.code === "artifact_verify_failed" ? "ota:errorPhoneVerify" : "ota:errorPhoneDownload"
68
+ }
69
+ case "manifest_invalid":
70
+ return "ota:errorPhoneVerify"
71
+ case "hotspot_wifi_permission_denied":
72
+ return "ota:errorPhoneNearbyPermission"
73
+ case "hotspot_start_failed":
74
+ return "ota:errorGlassesHotspotStart"
75
+ case "hotspot_join_failed":
76
+ return "ota:errorPhoneHotspotJoin"
77
+ case "hotspot_server_failed":
78
+ return "ota:errorPhoneShareUpdate"
79
+ default:
80
+ return OTA_ERROR_GENERIC_COPY_KEY
81
+ }
82
+ }
83
+
45
84
  /** English copy for every OTA error key above. Mirrored in the Mentra App's `ota` i18n namespace. */
46
85
  export const OTA_ERROR_ENGLISH_COPY: Readonly<Record<string, string>> = {
47
86
  "ota:errorDnsFailed": "Could not find the update server — retry, or check glasses Wi-Fi if this continues",
@@ -63,12 +102,12 @@ export const OTA_ERROR_ENGLISH_COPY: Readonly<Record<string, string>> = {
63
102
  "ota:errorNoInternet": "Glasses Wi-Fi has no internet connection",
64
103
  "ota:errorClockSkew": "Glasses clock is wrong — syncing time from your phone, then retrying update check",
65
104
  "ota:errorSslError": "Secure connection failed — try a different Wi-Fi network",
66
- "ota:errorDownloadFailed": "Download failed — check glasses Wi-Fi connection",
105
+ "ota:errorDownloadFailed": "The update could not be downloaded to your glasses. Please try again.",
67
106
  "ota:errorInsufficientStorage": "Not enough storage on your glasses — free up space before trying again",
68
107
  "ota:errorFirmwareTooLarge": "Firmware file is unexpectedly large — please contact support",
69
108
  "ota:errorFirmwareVerifyFailed": "Firmware verification failed — please try again or contact support",
70
109
  "ota:errorApkVerifyFailed": "Update verification failed — please try again or contact support",
71
- "ota:errorInstallFailed": "Install failed — please try again",
110
+ "ota:errorInstallFailed": "The update could not be installed on your glasses. Please try again.",
72
111
  "ota:errorApkRestartGuardNotPersisted":
73
112
  "Your glasses could not save the update before restarting. Restart your glasses and try again.",
74
113
  "ota:errorDowngradeHandoffRefused":
@@ -77,8 +116,23 @@ export const OTA_ERROR_ENGLISH_COPY: Readonly<Record<string, string>> = {
77
116
  "The recovery service on your glasses did not respond. Restart your glasses and try again.",
78
117
  "ota:errorDowngradeTransactionStalled":
79
118
  "The version change on your glasses did not finish. Restart your glasses and try again.",
80
- "ota:errorGlassesUnknown": "Your glasses reported an unexpected error. Restart your glasses and try again.",
81
- "ota:errorGeneric": "Update failed",
119
+ "ota:errorGlassesUnknown": "Something went wrong. Please try again.",
120
+ "ota:errorGeneric": "Something went wrong. Please try again.",
121
+ "ota:errorPhoneInternet": "Your phone could not connect to the internet. Check its connection and try again.",
122
+ "ota:errorPhoneConnection": "Your phone could not reach the update server. Check its connection and try again.",
123
+ "ota:errorPhoneDownloadServer": "The update server could not provide the download. Please try again later.",
124
+ "ota:errorPhoneDownloadTimeout": "The update download took too long. Please try again.",
125
+ "ota:errorPhoneStorage": "There is not enough space on your phone for the update. Free up space and try again.",
126
+ "ota:errorPhoneSave": "Your phone could not save the update. Please try again.",
127
+ "ota:errorPhoneSecureConnection":
128
+ "Your phone could not make a secure connection to the update server. Please try again later.",
129
+ "ota:errorPhoneDownload": "The update could not be downloaded to your phone. Please try again.",
130
+ "ota:errorPhoneVerify": "The update file could not be verified. Please try again later.",
131
+ "ota:errorPhoneNearbyPermission": "Allow nearby devices access so your phone can connect to your glasses.",
132
+ "ota:errorGlassesHotspotStart":
133
+ "Your glasses could not start the connection needed for the update. Please try again.",
134
+ "ota:errorPhoneHotspotJoin": "Your phone could not connect to your glasses for the update. Please try again.",
135
+ "ota:errorPhoneShareUpdate": "Your phone could not send the update to your glasses. Please try again.",
82
136
  "ota:errorBesRestartRequired": "Restart your glasses to safely exit firmware update mode before trying again",
83
137
  "ota:errorCode": "Error code: {{code}}",
84
138
  }
@@ -1,5 +1,6 @@
1
1
  import type {OtaProgress, OtaStatus} from "@mentra/bluetooth-sdk"
2
2
 
3
+ import type {OtaArtifactFailure} from "./OtaArtifactFailure"
3
4
  import {redactOtaDiagnosticError} from "../utils/otaDiagnosticRedaction"
4
5
 
5
6
  export type OtaFailureReason =
@@ -15,6 +16,7 @@ export interface OtaFailureEvidence {
15
16
  attemptId: string
16
17
  deviceRevision: number
17
18
  reason: OtaFailureReason
19
+ artifactFailure?: OtaArtifactFailure
18
20
  transport: "wifi" | "hotspot" | null
19
21
  hotspotPhase: string
20
22
  connected: boolean
@@ -23,9 +23,11 @@ import GlobalEventEmitter from "../utils/GlobalEventEmitter"
23
23
  import {isGlassesConnected, useGlassesStore} from "../stores/glasses"
24
24
  import {resolveOtaManifestUrl} from "./otaManifestUrl"
25
25
  import {hotspotOtaTransport, type HotspotOtaPhase} from "./HotspotOtaTransport"
26
+ import {OTA_ERROR_ENGLISH_COPY, phoneOtaErrorCopyKey} from "./OtaErrorMapping"
27
+ import type {OtaArtifactFailure} from "./OtaArtifactFailure"
26
28
  import {waitForOtaInternet} from "./OtaInternetRecovery"
27
29
  import {submitOtaFailureReport, type OtaFailureReason} from "./OtaFailureReportService"
28
- import type {OtaArtifactDownloadProgress} from "./OtaArtifactDownloader"
30
+ import {OtaArtifactError, type OtaArtifactDownloadProgress} from "./OtaArtifactDownloader"
29
31
  import {otaDeviceSessionRevision, subscribeOtaDeviceSession} from "./OtaDeviceSession"
30
32
  import {checkCurrentGlassesForUpdate, isOtaCheckForCurrentDevice} from "./OtaUpdateCheckService"
31
33
  import type {OtaCheckCurrentGlassesResult} from "./OtaUpdateCheckService"
@@ -67,28 +69,6 @@ function isTerminalForWatchdog(d: DisplayState): boolean {
67
69
  return d === "complete" || d === "failed" || d === "restarting"
68
70
  }
69
71
 
70
- function hotspotPreflightErrorMessage(error: unknown): string {
71
- const code =
72
- typeof error === "object" && error !== null && "code" in error ? String((error as {code: unknown}).code) : ""
73
- switch (code) {
74
- case "artifact_download_failed":
75
- return OtaProgressMessages.hotspotArtifactDownloadFailed
76
- case "artifact_verify_failed":
77
- case "manifest_invalid":
78
- return OtaProgressMessages.hotspotArtifactVerifyFailed
79
- case "hotspot_wifi_permission_denied":
80
- return OtaProgressMessages.hotspotWifiPermissionDenied
81
- case "hotspot_start_failed":
82
- return OtaProgressMessages.hotspotStartFailed
83
- case "hotspot_join_failed":
84
- return OtaProgressMessages.hotspotJoinFailed
85
- case "hotspot_server_failed":
86
- return OtaProgressMessages.hotspotServerFailed
87
- default:
88
- return OtaProgressMessages.hotspotServerFailed
89
- }
90
- }
91
-
92
72
  /** Non-empty when glasses are actively reporting work — drives stall timeout reset. */
93
73
  function buildProgressStalenessSignature(
94
74
  otaStatus: OtaStatus | null,
@@ -149,6 +129,7 @@ function hasRecoveringOtaReply(otaStatus: OtaStatus | null, otaProgress: OtaProg
149
129
  export interface OtaInstallSnapshot {
150
130
  displayState: DisplayState
151
131
  errorMsg: string
132
+ errorCopyKey?: string | null
152
133
  continueButtonDisabled: boolean
153
134
  connected: boolean
154
135
  otaStatus: OtaStatus | null
@@ -220,6 +201,7 @@ class OtaInstallCoordinator {
220
201
 
221
202
  // Genuinely session-local state (was component state/refs).
222
203
  private errorMsg = ""
204
+ private errorCopyKey: string | null = null
223
205
  private reportAttemptId = `${Date.now()}-0`
224
206
  private reportAttemptSequence = 0
225
207
  private finishCompletion: {attemptId: string; deviceRevision: number; promise: Promise<void>} | null = null
@@ -598,6 +580,7 @@ class OtaInstallCoordinator {
598
580
  versionChangeSession: this.versionChangeSession,
599
581
  }),
600
582
  errorMsg: this.errorMsg,
583
+ errorCopyKey: this.errorCopyKey,
601
584
  // Keep Continue disabled while the expected BES reboot is still in flight.
602
585
  continueButtonDisabled: this.continueButtonDisabled || this.besRestartRecovery === "awaiting",
603
586
  connected,
@@ -672,6 +655,7 @@ class OtaInstallCoordinator {
672
655
  private resetSessionState(): void {
673
656
  this.finishCompletion = null
674
657
  this.errorMsg = ""
658
+ this.errorCopyKey = null
675
659
  this.versionChangeReconnected = false
676
660
  this.sawReconnectEdge = false
677
661
  this.continueButtonDisabled = false
@@ -736,12 +720,13 @@ class OtaInstallCoordinator {
736
720
 
737
721
  // Setters mirror the old setState calls: bail on identical values (React's
738
722
  // setState bailout), notify snapshot subscribers, and queue a reaction pass.
739
- private reportFailure(reason: OtaFailureReason): void {
723
+ private reportFailure(reason: OtaFailureReason, artifactFailure?: OtaArtifactFailure): void {
740
724
  const state = useGlassesStore.getState()
741
725
  void submitOtaFailureReport({
742
726
  attemptId: this.reportAttemptId,
743
727
  deviceRevision: otaDeviceSessionRevision(),
744
728
  reason,
729
+ artifactFailure,
745
730
  transport: this.observedTransport,
746
731
  hotspotPhase: this.hotspotPhase,
747
732
  connected: isGlassesConnected(state.connection),
@@ -763,9 +748,10 @@ class OtaInstallCoordinator {
763
748
  })
764
749
  }
765
750
 
766
- private setErrorMsg(next: string, failureReason?: OtaFailureReason): void {
767
- if (this.errorMsg === next) return
751
+ private setErrorMsg(next: string, failureReason?: OtaFailureReason, copyKey: string | null = null): void {
752
+ if (this.errorMsg === next && this.errorCopyKey === copyKey) return
768
753
  this.errorMsg = next
754
+ this.errorCopyKey = copyKey
769
755
  if (next && failureReason) this.reportFailure(failureReason)
770
756
  this.emitInternalChange()
771
757
  this.react()
@@ -1736,10 +1722,13 @@ class OtaInstallCoordinator {
1736
1722
  if (this.selectedTransport === "hotspot" && !nativeStartAttempted) {
1737
1723
  ownership.outcome = "rejected"
1738
1724
  this.clearStuckTimeout()
1739
- this.reportFailure("hotspot_preflight_failed")
1725
+ this.reportFailure("hotspot_preflight_failed", err instanceof OtaArtifactError ? err.failure : undefined)
1740
1726
  await this.teardownHotspotTransport()
1741
1727
  if (!isCurrentAttempt()) return
1742
- if (this.attached) this.setErrorMsg(hotspotPreflightErrorMessage(err))
1728
+ if (this.attached) {
1729
+ const copyKey = phoneOtaErrorCopyKey(err)
1730
+ this.setErrorMsg(OTA_ERROR_ENGLISH_COPY[copyKey], undefined, copyKey)
1731
+ }
1743
1732
  return
1744
1733
  }
1745
1734