@mentra/engine 3.1.0-dev.9 → 3.1.0-dev.91

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 (109) hide show
  1. package/README.md +23 -5
  2. package/build/bluetooth-sdk/ota-transport.d.ts +2 -0
  3. package/build/bluetooth-sdk/ota-transport.d.ts.map +1 -0
  4. package/build/bluetooth-sdk/ota-transport.js +2 -0
  5. package/build/bluetooth-sdk/ota-transport.js.map +1 -0
  6. package/build/engine.d.ts +5 -2
  7. package/build/engine.d.ts.map +1 -1
  8. package/build/facades/ota.d.ts +11 -6
  9. package/build/facades/ota.d.ts.map +1 -1
  10. package/build/facades/ota.js +9 -5
  11. package/build/facades/ota.js.map +1 -1
  12. package/build/facades/pairing.d.ts +4 -0
  13. package/build/facades/pairing.d.ts.map +1 -1
  14. package/build/facades/pairing.js +81 -7
  15. package/build/facades/pairing.js.map +1 -1
  16. package/build/generated/releaseMetadata.js +5 -5
  17. package/build/generated/releaseMetadata.js.map +1 -1
  18. package/build/index.d.ts +2 -2
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js +1 -1
  21. package/build/index.js.map +1 -1
  22. package/build/react/MentraLiveOtaFlow.d.ts +8 -2
  23. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  24. package/build/react/MentraLiveOtaFlow.js +256 -354
  25. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  26. package/build/react/index.d.ts +1 -0
  27. package/build/react/index.d.ts.map +1 -1
  28. package/build/react/index.js +1 -0
  29. package/build/react/index.js.map +1 -1
  30. package/build/react/useMentraLiveOta.d.ts +84 -0
  31. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  32. package/build/react/useMentraLiveOta.js +627 -0
  33. package/build/react/useMentraLiveOta.js.map +1 -0
  34. package/build/services/BlobStore.d.ts +2 -0
  35. package/build/services/BlobStore.d.ts.map +1 -1
  36. package/build/services/BlobStore.js +63 -10
  37. package/build/services/BlobStore.js.map +1 -1
  38. package/build/services/GlassesStatusProjection.d.ts +15 -2
  39. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  40. package/build/services/GlassesStatusProjection.js +3 -3
  41. package/build/services/GlassesStatusProjection.js.map +1 -1
  42. package/build/services/HotspotOtaTransport.d.ts.map +1 -1
  43. package/build/services/HotspotOtaTransport.js +8 -7
  44. package/build/services/HotspotOtaTransport.js.map +1 -1
  45. package/build/services/OtaAutoChain.d.ts +9 -2
  46. package/build/services/OtaAutoChain.d.ts.map +1 -1
  47. package/build/services/OtaAutoChain.js +10 -2
  48. package/build/services/OtaAutoChain.js.map +1 -1
  49. package/build/services/OtaInstallCoordinator.d.ts +26 -3
  50. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  51. package/build/services/OtaInstallCoordinator.js +35 -13
  52. package/build/services/OtaInstallCoordinator.js.map +1 -1
  53. package/build/services/OtaService.d.ts.map +1 -1
  54. package/build/services/OtaService.js +1 -1
  55. package/build/services/OtaService.js.map +1 -1
  56. package/build/services/OtaUpdateCheckService.d.ts +4 -1
  57. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  58. package/build/services/OtaUpdateCheckService.js +21 -5
  59. package/build/services/OtaUpdateCheckService.js.map +1 -1
  60. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  61. package/build/services/asg/localNetworkTransport.js +13 -12
  62. package/build/services/asg/localNetworkTransport.js.map +1 -1
  63. package/build/services/glassesClockSync.js +1 -1
  64. package/build/services/glassesClockSync.js.map +1 -1
  65. package/build/services/otaInstallPolicy.d.ts +4 -0
  66. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  67. package/build/services/otaInstallPolicy.js +4 -0
  68. package/build/services/otaInstallPolicy.js.map +1 -1
  69. package/build/services/otaReleaseVersion.d.ts +10 -0
  70. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  71. package/build/services/otaReleaseVersion.js +14 -0
  72. package/build/services/otaReleaseVersion.js.map +1 -0
  73. package/build/stores/appVisibility.d.ts +3 -0
  74. package/build/stores/appVisibility.d.ts.map +1 -0
  75. package/build/stores/appVisibility.js +3 -0
  76. package/build/stores/appVisibility.js.map +1 -0
  77. package/build/stores/apps.d.ts.map +1 -1
  78. package/build/stores/apps.js +7 -1
  79. package/build/stores/apps.js.map +1 -1
  80. package/build/stores/core.d.ts +3 -3
  81. package/build/stores/core.d.ts.map +1 -1
  82. package/build/stores/core.js.map +1 -1
  83. package/build/stores/glasses.d.ts +10 -4
  84. package/build/stores/glasses.d.ts.map +1 -1
  85. package/build/stores/glasses.js.map +1 -1
  86. package/package.json +13 -6
  87. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  88. package/src/facades/ota.ts +19 -6
  89. package/src/facades/pairing.ts +88 -12
  90. package/src/generated/releaseMetadata.ts +5 -5
  91. package/src/index.ts +2 -0
  92. package/src/react/MentraLiveOtaFlow.tsx +378 -480
  93. package/src/react/index.ts +14 -0
  94. package/src/react/useMentraLiveOta.ts +793 -0
  95. package/src/services/BlobStore.ts +57 -10
  96. package/src/services/GlassesStatusProjection.ts +7 -6
  97. package/src/services/HotspotOtaTransport.ts +8 -7
  98. package/src/services/OtaAutoChain.ts +26 -2
  99. package/src/services/OtaInstallCoordinator.ts +37 -13
  100. package/src/services/OtaService.ts +1 -2
  101. package/src/services/OtaUpdateCheckService.ts +26 -8
  102. package/src/services/asg/localNetworkTransport.ts +20 -19
  103. package/src/services/glassesClockSync.ts +1 -1
  104. package/src/services/otaInstallPolicy.ts +4 -0
  105. package/src/services/otaReleaseVersion.ts +22 -0
  106. package/src/stores/appVisibility.ts +3 -0
  107. package/src/stores/apps.ts +7 -1
  108. package/src/stores/core.ts +4 -4
  109. package/src/stores/glasses.ts +13 -6
@@ -1,27 +1,27 @@
1
- import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"
2
- import {ActivityIndicator, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle} from "react-native"
1
+ /* eslint-disable react-native/no-raw-text -- BodyText and PercentText are local Text wrappers. */
2
+ import React, {useMemo} from "react"
3
+ import {
4
+ ActivityIndicator,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ } from "react-native"
13
+ import {useMarkdown, type MarkedStyles, type useMarkdownHookOptions} from "react-native-marked"
3
14
  import {SafeAreaView} from "react-native-safe-area-context"
4
15
  import Svg, {Path, Rect} from "react-native-svg"
5
16
 
6
- import {ota} from "../facades/ota"
7
- import {
8
- beginOtaAutoChain,
9
- clearOtaAutoChainReconnectWait,
10
- isOtaAutoChainActive,
11
- otaAutoChainFingerprint,
12
- otaAutoChainReconnectWaitRemaining,
13
- stopOtaAutoChain,
14
- tryAdvanceOtaAutoChain,
15
- } from "../services/OtaAutoChain"
16
17
  import {
17
- BES_INSTALL_RESTART_MESSAGE,
18
- getOtaErrorMessage,
19
- shouldRequireGlassesRebootForBesFailure,
20
- shouldShowChangeWifiForOtaDownloadFailure,
21
- } from "../services/OtaErrorMapping"
22
- import {useEngineSnapshot} from "./useEngineSnapshot"
18
+ MINIMUM_OTA_BATTERY_LEVEL,
19
+ useMentraLiveOta,
20
+ type MentraLiveOtaController,
21
+ type MentraLiveOtaFlowPage,
22
+ } from "./useMentraLiveOta"
23
23
 
24
- export type MentraLiveOtaFlowPage = "check" | "progress"
24
+ export type {MentraLiveOtaFlowPage} from "./useMentraLiveOta"
25
25
 
26
26
  export type MentraLiveOtaFlowTheme = {
27
27
  background: string
@@ -40,7 +40,7 @@ export type MentraLiveOtaFlowProps = {
40
40
  deviceName?: string
41
41
  /** Entry page. `progress` exists for recovery/deep-link compatibility. */
42
42
  initialPage?: MentraLiveOtaFlowPage
43
- /** Start the OTA-only projections. Full engine hosts should pass false. */
43
+ /** Start the OTA-only projections. Full Engine hosts should pass false. */
44
44
  initializeRuntime?: boolean
45
45
  /** Called after the final check or when the user leaves an optional update. */
46
46
  onFinished: () => void
@@ -59,11 +59,6 @@ export type MentraLiveOtaFlowProps = {
59
59
  style?: StyleProp<ViewStyle>
60
60
  }
61
61
 
62
- type CheckState = "checking" | "update_available" | "no_update" | "dev_build" | "error"
63
-
64
- const AUTO_CHAIN_NETWORK_RETRY_DELAY_MS = 5000
65
- const AUTO_CHAIN_COMPLETE_DELAY_MS = 750
66
-
67
62
  const DEFAULT_THEME: MentraLiveOtaFlowTheme = {
68
63
  background: "#FFFFFF",
69
64
  border: "#D7DFDA",
@@ -76,19 +71,34 @@ const DEFAULT_THEME: MentraLiveOtaFlowTheme = {
76
71
 
77
72
  const ENGLISH_COPY: Record<string, string> = {
78
73
  "common:continue": "Continue",
74
+ "common:done": "Done",
79
75
  "ota:checkingForUpdates": "Checking for updates",
80
76
  "ota:checkingForUpdatesMessage":
81
77
  "Connected devices will perform automatic updates. Automatic updates can be disabled in Device Settings",
78
+ "ota:finishingUpdate": "Finishing your update",
79
+ "ota:checkingAdditionalUpdates": "Checking whether your glasses need any additional updates.",
82
80
  "ota:updateAvailable": "{{deviceName}} Update Available",
81
+ "ota:batteryRequiredTitle": "Charge {{deviceName}} to Update",
82
+ "ota:batteryRequiredMessage":
83
+ "{{deviceName}} is currently at {{batteryLevel}}%. Charge it to at least {{minimumBatteryLevel}}% before updating.",
84
+ "ota:batteryRequiredLiveUpdate": "This screen will update automatically as the battery charges.",
83
85
  "ota:updateConnectWifi": "Connect your {{deviceName}} to WiFi to install the update.",
86
+ "ota:wifiRequiredTitle": "WiFi Needed for Update",
84
87
  "ota:updateDescription":
85
88
  "A new update is available for your glasses. We recommend updating now for the best experience.",
89
+ "ota:updateSequenceMessage":
90
+ "Your glasses may install more than one update and restart several times. Keep them nearby until finished.",
91
+ "ota:releaseTransition": "{{fromVersion}} → {{toVersion}}",
92
+ "ota:releaseTransitionUnknown": "Current version unknown → {{toVersion}}",
93
+ "ota:updatedToVersion": "Updated to {{version}}",
86
94
  "ota:downgradeAvailable": "{{deviceName}} Version Change Required",
87
95
  "ota:downgradeDescription":
88
96
  "This app requires an earlier glasses software version. Your photos and videos will be preserved, but glasses settings will be reset and restored automatically after the change.",
89
97
  "ota:updateNow": "Update Now",
90
98
  "ota:setupWifi": "Setup WiFi",
91
99
  "ota:updateLater": "Later",
100
+ "ota:updateComplete": "Update complete",
101
+ "ota:whatsNew": "What's new",
92
102
  "ota:upToDate": "Up To Date",
93
103
  "ota:devBuild": "Development Build",
94
104
  "ota:devBuildNoOta":
@@ -103,6 +113,10 @@ const ENGLISH_COPY: Record<string, string> = {
103
113
  "ota:versionChangeRestarting": "Installing a different version…",
104
114
  "ota:versionChangeVerifying": "Verifying your glasses…",
105
115
  "ota:versionChangeKeepNearby": "Keep your glasses nearby and connected. They will restart on their own.",
116
+ "ota:restartingGlasses": "Restarting {{deviceName}}…",
117
+ "ota:restartingGlassesMessage":
118
+ "The update is installed. Keep your glasses nearby and leave this screen open while they finish starting.",
119
+ "ota:restartingGlassesAutomatic": "We'll continue automatically when they're ready.",
106
120
  "ota:versionChangeComplete": "Version Change Complete",
107
121
  "ota:versionChangeCompleteMessage":
108
122
  "Your glasses are now on the required version. Their settings were reset and are being restored automatically.",
@@ -133,29 +147,13 @@ export function MentraLiveOtaFlow({
133
147
  translate = defaultTranslate,
134
148
  }: MentraLiveOtaFlowProps) {
135
149
  const colors = useMemo(() => ({...DEFAULT_THEME, ...theme}), [theme])
136
- const [page, setPage] = useState<MentraLiveOtaFlowPage>(initialPage)
137
- const [checkGeneration, setCheckGeneration] = useState(0)
138
- const [runtimeReady, setRuntimeReady] = useState(!initializeRuntime)
139
-
140
- useEffect(() => {
141
- if (!initializeRuntime) {
142
- setRuntimeReady(true)
143
- return
144
- }
145
- let cancelled = false
146
- setRuntimeReady(false)
147
- void ota.initialize().finally(() => {
148
- if (!cancelled) setRuntimeReady(true)
149
- })
150
- return () => {
151
- cancelled = true
152
- }
153
- }, [initializeRuntime])
154
-
155
- const showCheck = useCallback(() => {
156
- setPage("check")
157
- setCheckGeneration((generation) => generation + 1)
158
- }, [])
150
+ const controller = useMentraLiveOta({
151
+ initialPage,
152
+ initializeRuntime,
153
+ onFinished,
154
+ onFirmwareRestartingChange,
155
+ onOpenWifiSetup,
156
+ })
159
157
 
160
158
  return (
161
159
  <SafeAreaView style={[styles.safeArea, {backgroundColor: colors.background}, style]}>
@@ -163,300 +161,139 @@ export function MentraLiveOtaFlow({
163
161
  <View />
164
162
  <MentraMark color={colors.primary} />
165
163
  </View>
166
- {!runtimeReady ? (
167
- <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
168
- <ActivityIndicator size="large" color={colors.foreground} />
169
- </FlowPage>
170
- ) : page === "check" ? (
171
- <OtaCheckPage
172
- allowDevSkip={allowDevSkip}
173
- colors={colors}
174
- deviceName={deviceName}
175
- generation={checkGeneration}
176
- onFinished={onFinished}
177
- onOpenWifiSetup={onOpenWifiSetup}
178
- onStartProgress={() => setPage("progress")}
179
- translate={translate}
180
- />
181
- ) : (
182
- <OtaProgressPage
183
- colors={colors}
184
- onFirmwareRestartingChange={onFirmwareRestartingChange}
185
- onOpenWifiSetup={onOpenWifiSetup}
186
- onReturnToCheck={showCheck}
187
- superMode={superMode}
188
- translate={translate}
189
- />
190
- )}
164
+ <OtaFlowContent
165
+ allowDevSkip={allowDevSkip}
166
+ colors={colors}
167
+ controller={controller}
168
+ deviceName={deviceName}
169
+ superMode={superMode}
170
+ translate={translate}
171
+ />
191
172
  </SafeAreaView>
192
173
  )
193
174
  }
194
175
 
195
- type CheckPageProps = {
176
+ function OtaFlowContent({
177
+ allowDevSkip,
178
+ colors,
179
+ controller,
180
+ deviceName,
181
+ superMode,
182
+ translate,
183
+ }: {
196
184
  allowDevSkip: boolean
197
185
  colors: MentraLiveOtaFlowTheme
186
+ controller: MentraLiveOtaController
198
187
  deviceName: string
199
- generation: number
200
- onFinished: () => void
201
- onOpenWifiSetup: () => void
202
- onStartProgress: () => void
188
+ superMode: boolean
203
189
  translate: MentraLiveOtaFlowTranslate
204
- }
190
+ }) {
191
+ const {state} = controller
205
192
 
206
- function OtaCheckPage({
207
- allowDevSkip,
208
- colors,
209
- deviceName,
210
- generation,
211
- onFinished,
212
- onOpenWifiSetup,
213
- onStartProgress,
214
- translate,
215
- }: CheckPageProps) {
216
- const otaSnapshot = useEngineSnapshot(ota.snapshot, ota.onSnapshot)
217
- const [checkState, setCheckState] = useState<CheckState>("checking")
218
- const [isUpdateRequired, setIsUpdateRequired] = useState(true)
219
- const [isDowngradeUpdate, setIsDowngradeUpdate] = useState(false)
220
- const [errorKind, setErrorKind] = useState<"network" | "pin_unavailable">("network")
221
- const [updateFingerprint, setUpdateFingerprint] = useState<string | null>(null)
222
- const [retryGeneration, setRetryGeneration] = useState(0)
223
- const performCheckGenerationRef = useRef(0)
224
- const activeCheckKeyRef = useRef<string | null>(null)
225
- const checkStartedRef = useRef(false)
226
- const checkCompletedRef = useRef(false)
227
- const selectedCheckResultRef = useRef<Awaited<ReturnType<typeof ota.checkForUpdates>> | null>(null)
228
- const onFinishedRef = useRef(onFinished)
229
- const onStartProgressRef = useRef(onStartProgress)
230
- onFinishedRef.current = onFinished
231
- onStartProgressRef.current = onStartProgress
232
-
233
- const glassesWifiConnected = otaSnapshot.wifiConnected
234
- const glassesWifiStatusKnown = otaSnapshot.wifiStatusKnown
235
- const hotspotOtaSupported = otaSnapshot.hotspotOtaVersion === 1
236
- const canInstallUpdate = glassesWifiStatusKnown && (glassesWifiConnected || hotspotOtaSupported)
237
- const checkKey = `${generation}:${retryGeneration}`
238
-
239
- const navigateToProgress = useCallback(() => {
240
- ota.clearProgress()
241
- onStartProgressRef.current()
242
- }, [])
243
-
244
- useEffect(() => {
245
- const MIN_DISPLAY_TIME_MS = 1100
246
- const MAX_WAIT_FOR_VERSION_INFO_MS = 10_000
247
- if (activeCheckKeyRef.current !== checkKey) {
248
- activeCheckKeyRef.current = checkKey
249
- checkStartedRef.current = false
250
- checkCompletedRef.current = false
251
- setCheckState("checking")
252
- }
253
-
254
- const myGeneration = ++performCheckGenerationRef.current
255
- let cancelled = false
256
- let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
257
-
258
- const performCheck = async () => {
259
- if (checkCompletedRef.current) return
260
- if (!otaSnapshot.connected) {
261
- if (isOtaAutoChainActive()) {
262
- const remainingMs = otaAutoChainReconnectWaitRemaining()
263
- if (remainingMs === null) return
264
- reconnectTimeout = setTimeout(() => {
265
- if (cancelled || myGeneration !== performCheckGenerationRef.current || ota.snapshot().connected) return
266
- stopOtaAutoChain()
267
- checkCompletedRef.current = true
268
- setCheckState("error")
269
- }, remainingMs)
270
- return
271
- }
272
- if (checkStartedRef.current) {
273
- stopOtaAutoChain()
274
- checkCompletedRef.current = true
275
- setCheckState("error")
276
- return
277
- }
278
- checkCompletedRef.current = true
279
- onFinishedRef.current()
280
- return
281
- }
193
+ if (state.screen === "initializing") {
194
+ return (
195
+ <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
196
+ <ActivityIndicator size="large" color={colors.foreground} />
197
+ </FlowPage>
198
+ )
199
+ }
282
200
 
283
- clearOtaAutoChainReconnectWait()
284
- checkStartedRef.current = true
285
- const startTime = Date.now()
286
- try {
287
- let result = await ota.checkForUpdates({
288
- waitForBuildNumberMs: MAX_WAIT_FOR_VERSION_INFO_MS,
289
- waitForBesVersionMs: 5000,
290
- waitForMtkVersionMs: 2000,
291
- refreshVersionInfo: true,
292
- fixClockBeforeCheck: false,
293
- })
294
- if (cancelled || myGeneration !== performCheckGenerationRef.current) return
295
- if (!result.hasCheckCompleted && result.checkFailureReason === "network" && isOtaAutoChainActive()) {
296
- await new Promise((resolve) => setTimeout(resolve, AUTO_CHAIN_NETWORK_RETRY_DELAY_MS))
297
- if (cancelled || myGeneration !== performCheckGenerationRef.current) return
298
- result = await ota.checkForUpdates({
299
- waitForBuildNumberMs: MAX_WAIT_FOR_VERSION_INFO_MS,
300
- waitForBesVersionMs: 5000,
301
- waitForMtkVersionMs: 2000,
302
- refreshVersionInfo: true,
303
- fixClockBeforeCheck: false,
304
- })
305
- }
306
- if (cancelled || myGeneration !== performCheckGenerationRef.current) return
307
- selectedCheckResultRef.current = result
308
- await new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_DISPLAY_TIME_MS - (Date.now() - startTime))))
309
- if (cancelled || myGeneration !== performCheckGenerationRef.current) return
310
-
311
- if (result.skippedReason === "disconnected") {
312
- stopOtaAutoChain()
313
- checkCompletedRef.current = true
314
- setCheckState("error")
315
- return
316
- }
317
- if (result.skippedReason === "missing_build") {
318
- checkCompletedRef.current = true
319
- if (isOtaAutoChainActive()) {
320
- stopOtaAutoChain()
321
- setCheckState("error")
322
- } else {
323
- onFinishedRef.current()
324
- }
325
- return
326
- }
327
- if (result.skippedReason === "dev_build") {
328
- stopOtaAutoChain()
329
- checkCompletedRef.current = true
330
- ota.clearUpdateAvailable()
331
- setCheckState("dev_build")
332
- return
333
- }
334
- if (!result.hasCheckCompleted) {
335
- stopOtaAutoChain()
336
- checkCompletedRef.current = true
337
- setErrorKind(result.checkFailureReason === "pin_unavailable" ? "pin_unavailable" : "network")
338
- setCheckState("error")
339
- return
340
- }
341
- if (result.updateAvailable && result.updateInfo) {
342
- setIsUpdateRequired(result.isRequired)
343
- setIsDowngradeUpdate(result.updateInfo.isDowngrade === true)
344
- const fingerprint = otaAutoChainFingerprint(result)
345
- setUpdateFingerprint(fingerprint)
346
- if (isOtaAutoChainActive()) {
347
- const snapshot = ota.snapshot()
348
- if (!snapshot.wifiStatusKnown) return
349
- if (!snapshot.wifiConnected && snapshot.hotspotOtaVersion !== 1) {
350
- stopOtaAutoChain()
351
- } else {
352
- const admission = tryAdvanceOtaAutoChain(fingerprint, result.updateInfo.isDowngrade === true)
353
- if (admission.advance) {
354
- checkCompletedRef.current = true
355
- ota.installSession.prepare(result)
356
- navigateToProgress()
357
- return
358
- }
359
- }
360
- }
361
- checkCompletedRef.current = true
362
- setCheckState("update_available")
363
- return
364
- }
365
- stopOtaAutoChain()
366
- checkCompletedRef.current = true
367
- ota.clearUpdateAvailable()
368
- setCheckState("no_update")
369
- } catch (error) {
370
- console.error("OTA check failed:", error)
371
- await new Promise((resolve) => setTimeout(resolve, Math.max(0, MIN_DISPLAY_TIME_MS - (Date.now() - startTime))))
372
- if (cancelled || myGeneration !== performCheckGenerationRef.current) return
373
- stopOtaAutoChain()
374
- checkCompletedRef.current = true
375
- setCheckState("error")
376
- }
377
- }
378
-
379
- void performCheck()
380
- return () => {
381
- cancelled = true
382
- if (reconnectTimeout) clearTimeout(reconnectTimeout)
383
- }
384
- }, [
385
- checkKey,
386
- otaSnapshot.connected,
387
- otaSnapshot.wifiStatusKnown,
388
- navigateToProgress,
389
- ])
390
-
391
- const retry = () => {
392
- setCheckState("checking")
393
- setRetryGeneration((value) => value + 1)
201
+ if (state.screen === "checking") {
202
+ return (
203
+ <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
204
+ <BodyText colors={colors}>{translate("ota:checkingForUpdatesMessage")}</BodyText>
205
+ <ActivityIndicator size="large" color={colors.foreground} />
206
+ </FlowPage>
207
+ )
394
208
  }
395
209
 
396
- const updateNow = () => {
397
- const result = selectedCheckResultRef.current
398
- if (!result) {
399
- setCheckState("error")
400
- return
401
- }
402
- const snapshot = ota.snapshot()
403
- if (!snapshot.wifiStatusKnown) {
404
- retry()
405
- return
406
- }
407
- if (!snapshot.wifiConnected && snapshot.hotspotOtaVersion !== 1) {
408
- onOpenWifiSetup()
409
- return
410
- }
411
- ota.installSession.prepare(result)
412
- if (updateFingerprint) beginOtaAutoChain(updateFingerprint, isDowngradeUpdate)
413
- navigateToProgress()
210
+ if (state.screen === "battery_required") {
211
+ return (
212
+ <FlowPage
213
+ actions={
214
+ <>
215
+ <FlowButton colors={colors} disabled label={translate("ota:updateNow")} onPress={controller.install} />
216
+ {state.canDismiss ? (
217
+ <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={controller.finish} secondary />
218
+ ) : null}
219
+ </>
220
+ }
221
+ colors={colors}
222
+ icon="alert"
223
+ title={translate("ota:batteryRequiredTitle", {deviceName})}>
224
+ <BodyText colors={colors}>
225
+ {translate("ota:batteryRequiredMessage", {
226
+ batteryLevel: String(state.batteryLevel),
227
+ deviceName,
228
+ minimumBatteryLevel: String(MINIMUM_OTA_BATTERY_LEVEL),
229
+ })}
230
+ </BodyText>
231
+ <BodyText colors={colors}>{translate("ota:batteryRequiredLiveUpdate")}</BodyText>
232
+ </FlowPage>
233
+ )
414
234
  }
415
235
 
416
- if (checkState === "checking") {
236
+ if (state.screen === "finishing") {
417
237
  return (
418
- <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
419
- <BodyText colors={colors}>{translate("ota:checkingForUpdatesMessage")}</BodyText>
238
+ <FlowPage colors={colors} icon="download" title={translate("ota:finishingUpdate")}>
239
+ <BodyText colors={colors}>{translate("ota:checkingAdditionalUpdates")}</BodyText>
420
240
  <ActivityIndicator size="large" color={colors.foreground} />
421
241
  </FlowPage>
422
242
  )
423
243
  }
424
244
 
425
- if (checkState === "update_available") {
245
+ if (state.screen === "update_available" || state.screen === "wifi_required") {
246
+ const titleKey =
247
+ state.screen === "wifi_required"
248
+ ? "ota:wifiRequiredTitle"
249
+ : state.versionChange
250
+ ? "ota:downgradeAvailable"
251
+ : "ota:updateAvailable"
426
252
  return (
427
253
  <FlowPage
428
254
  colors={colors}
429
255
  icon="download"
430
- title={translate(isDowngradeUpdate ? "ota:downgradeAvailable" : "ota:updateAvailable", {deviceName})}
256
+ title={translate(titleKey, {deviceName})}
431
257
  actions={
432
258
  <>
433
259
  <FlowButton
434
260
  colors={colors}
435
- disabled={!glassesWifiStatusKnown}
436
- label={translate(!glassesWifiStatusKnown || canInstallUpdate ? "ota:updateNow" : "ota:setupWifi")}
437
- onPress={updateNow}
261
+ disabled={!state.wifiStatusKnown}
262
+ label={translate(state.screen === "wifi_required" ? "ota:setupWifi" : "ota:updateNow")}
263
+ onPress={state.screen === "wifi_required" ? controller.openWifiSetup : controller.install}
438
264
  />
439
- {!isUpdateRequired ? (
440
- <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={onFinished} secondary />
265
+ {state.canDismiss ? (
266
+ <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={controller.finish} secondary />
441
267
  ) : null}
442
- {allowDevSkip && isUpdateRequired ? (
443
- <FlowButton colors={colors} label="Skip (dev only)" onPress={onFinished} secondary />
268
+ {allowDevSkip && state.updateRequired ? (
269
+ <FlowButton colors={colors} label="Skip (dev only)" onPress={controller.finish} secondary />
444
270
  ) : null}
445
271
  </>
446
272
  }>
273
+ {state.releaseTransition ? (
274
+ <BodyText colors={colors}>
275
+ {state.releaseTransition.fromVersion
276
+ ? translate("ota:releaseTransition", {
277
+ fromVersion: state.releaseTransition.fromVersion,
278
+ toVersion: state.releaseTransition.toVersion,
279
+ })
280
+ : translate("ota:releaseTransitionUnknown", {toVersion: state.releaseTransition.toVersion})}
281
+ </BodyText>
282
+ ) : null}
447
283
  <BodyText colors={colors}>
448
- {canInstallUpdate
449
- ? translate(isDowngradeUpdate ? "ota:downgradeDescription" : "ota:updateDescription")
450
- : translate("ota:updateConnectWifi", {deviceName})}
284
+ {state.screen === "wifi_required"
285
+ ? translate("ota:updateConnectWifi", {deviceName})
286
+ : translate(state.versionChange ? "ota:downgradeDescription" : "ota:updateDescription")}
451
287
  </BodyText>
288
+ <BodyText colors={colors}>{translate("ota:updateSequenceMessage")}</BodyText>
452
289
  </FlowPage>
453
290
  )
454
291
  }
455
292
 
456
- if (checkState === "dev_build") {
293
+ if (state.screen === "dev_build") {
457
294
  return (
458
295
  <FlowPage
459
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
296
+ actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={controller.finish} />}
460
297
  colors={colors}
461
298
  icon="settings"
462
299
  title={translate("ota:devBuild")}>
@@ -465,22 +302,35 @@ function OtaCheckPage({
465
302
  )
466
303
  }
467
304
 
468
- if (checkState === "no_update") {
305
+ if (state.screen === "up_to_date") {
469
306
  return (
470
307
  <FlowPage
471
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
308
+ actions={
309
+ <FlowButton
310
+ colors={colors}
311
+ label={translate(state.completedUpdate ? "common:done" : "common:continue")}
312
+ onPress={controller.finish}
313
+ />
314
+ }
472
315
  colors={colors}
316
+ contentAlignment={state.changelogs.length > 0 ? "top" : "center"}
473
317
  icon="check"
474
- title={translate("ota:upToDate")}>
318
+ title={translate(state.completedUpdate ? "ota:updateComplete" : "ota:upToDate")}>
475
319
  <BodyText colors={colors}>{translate("ota:noUpdatesAvailable")}</BodyText>
320
+ {state.releaseTransition ? (
321
+ <BodyText colors={colors}>
322
+ {translate("ota:updatedToVersion", {version: state.releaseTransition.toVersion})}
323
+ </BodyText>
324
+ ) : null}
325
+ <ChangelogList changelogs={state.changelogs} colors={colors} title={translate("ota:whatsNew")} />
476
326
  </FlowPage>
477
327
  )
478
328
  }
479
329
 
480
- if (errorKind === "pin_unavailable") {
330
+ if (state.screen === "update_info_unavailable") {
481
331
  return (
482
332
  <FlowPage
483
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
333
+ actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={controller.finish} />}
484
334
  colors={colors}
485
335
  icon="alert"
486
336
  title={translate("ota:updateInfoUnavailable")}>
@@ -489,104 +339,32 @@ function OtaCheckPage({
489
339
  )
490
340
  }
491
341
 
492
- return (
493
- <FlowPage
494
- actions={
495
- <>
496
- <FlowButton colors={colors} label="Retry" onPress={retry} />
497
- {allowDevSkip ? <FlowButton colors={colors} label="Skip (dev only)" onPress={onFinished} secondary /> : null}
498
- </>
499
- }
500
- colors={colors}
501
- icon="alert"
502
- title={translate("ota:checkFailed")}>
503
- <BodyText colors={colors}>{translate("ota:checkFailedMessage")}</BodyText>
504
- </FlowPage>
505
- )
506
- }
507
-
508
- type ProgressPageProps = {
509
- colors: MentraLiveOtaFlowTheme
510
- onFirmwareRestartingChange?: (restarting: boolean, progressActive: boolean) => void
511
- onOpenWifiSetup: () => void
512
- onReturnToCheck: () => void
513
- superMode: boolean
514
- translate: MentraLiveOtaFlowTranslate
515
- }
516
-
517
- function OtaProgressPage({
518
- colors,
519
- onFirmwareRestartingChange,
520
- onOpenWifiSetup,
521
- onReturnToCheck,
522
- superMode,
523
- translate,
524
- }: ProgressPageProps) {
525
- const install = useEngineSnapshot(ota.installSession.snapshot, ota.installSession.onSnapshot)
526
- const autoChainAdvancedRef = useRef(false)
527
- const {
528
- connected,
529
- continueButtonDisabled,
530
- displayState,
531
- errorMsg,
532
- hotspotArtifactPercent,
533
- hotspotPhase,
534
- isVersionChange,
535
- mtkInstallStallSimulatedPercent,
536
- otaProgress,
537
- otaStatus,
538
- versionChangeConverged,
539
- versionChangePhase,
540
- } = install
541
- const firmwareRestarting = (!connected && displayState === "restarting") || versionChangePhase === "restarting"
542
-
543
- useEffect(() => {
544
- onFirmwareRestartingChange?.(firmwareRestarting, true)
545
- }, [firmwareRestarting, onFirmwareRestartingChange])
546
-
547
- useEffect(
548
- () => () => {
549
- onFirmwareRestartingChange?.(false, false)
550
- },
551
- [onFirmwareRestartingChange],
552
- )
553
-
554
- useEffect(() => {
555
- ota.installSession.attach()
556
- return () => ota.installSession.detach()
557
- }, [])
558
-
559
- useEffect(() => {
560
- if (displayState !== "complete" || !connected || !isOtaAutoChainActive() || autoChainAdvancedRef.current) return
561
- const timeout = setTimeout(() => {
562
- if (!isOtaAutoChainActive()) return
563
- autoChainAdvancedRef.current = true
564
- ota.installSession.finish()
565
- onReturnToCheck()
566
- }, AUTO_CHAIN_COMPLETE_DELAY_MS)
567
- return () => clearTimeout(timeout)
568
- }, [connected, displayState, onReturnToCheck])
569
-
570
- const finishAndCheck = () => {
571
- ota.installSession.finish()
572
- onReturnToCheck()
573
- }
574
- const stopAndCheck = () => {
575
- stopOtaAutoChain()
576
- finishAndCheck()
577
- }
578
- const retryInstall = () => {
579
- onFirmwareRestartingChange?.(false, true)
580
- ota.installSession.retry()
342
+ if (state.screen === "check_failed") {
343
+ return (
344
+ <FlowPage
345
+ actions={
346
+ <>
347
+ <FlowButton colors={colors} label="Retry" onPress={controller.retryCheck} />
348
+ {allowDevSkip ? (
349
+ <FlowButton colors={colors} label="Skip (dev only)" onPress={controller.finish} secondary />
350
+ ) : null}
351
+ </>
352
+ }
353
+ colors={colors}
354
+ icon="alert"
355
+ title={translate("ota:checkFailed")}>
356
+ <BodyText colors={colors}>{translate("ota:checkFailedMessage")}</BodyText>
357
+ </FlowPage>
358
+ )
581
359
  }
582
360
 
583
- if (versionChangePhase === "restarting" || versionChangePhase === "verifying") {
361
+ if (state.versionChangePhase === "restarting" || state.versionChangePhase === "verifying") {
584
362
  return (
585
363
  <FlowPage
586
364
  colors={colors}
587
365
  icon="download"
588
366
  title={translate(
589
- versionChangePhase === "verifying" ? "ota:versionChangeVerifying" : "ota:versionChangeRestarting",
367
+ state.versionChangePhase === "verifying" ? "ota:versionChangeVerifying" : "ota:versionChangeRestarting",
590
368
  )}>
591
369
  <ActivityIndicator size="large" color={colors.foreground} />
592
370
  <BodyText colors={colors}>{translate("ota:versionChangeKeepNearby")}</BodyText>
@@ -595,19 +373,19 @@ function OtaProgressPage({
595
373
  )
596
374
  }
597
375
 
598
- if (displayState === "starting") {
599
- const hotspotTitle =
600
- hotspotPhase === "downloading"
376
+ if (state.screen === "starting" || state.screen === "preparing_hotspot") {
377
+ const title =
378
+ state.hotspotPhase === "downloading"
601
379
  ? "Downloading update to phone..."
602
- : hotspotPhase === "starting_hotspot"
603
- ? "Starting glasses hotspot..."
604
- : hotspotPhase === "joining_hotspot"
605
- ? "Connecting phone to glasses..."
606
- : "Starting update..."
380
+ : state.hotspotPhase === "starting_hotspot"
381
+ ? "Starting glasses hotspot..."
382
+ : state.hotspotPhase === "joining_hotspot"
383
+ ? "Connecting phone to glasses..."
384
+ : "Starting update..."
607
385
  return (
608
- <FlowPage colors={colors} icon="download" title={hotspotTitle}>
609
- {hotspotPhase === "downloading" && hotspotArtifactPercent !== null ? (
610
- <PercentText colors={colors} percent={hotspotArtifactPercent} />
386
+ <FlowPage colors={colors} icon="download" title={title}>
387
+ {state.hotspotPhase === "downloading" && state.hotspotArtifactPercent !== null ? (
388
+ <PercentText colors={colors} percent={state.hotspotArtifactPercent} />
611
389
  ) : null}
612
390
  <ActivityIndicator size="large" color={colors.foreground} />
613
391
  <BodyText colors={colors}>Do not disconnect your glasses</BodyText>
@@ -615,109 +393,93 @@ function OtaProgressPage({
615
393
  )
616
394
  }
617
395
 
618
- if (displayState === "updating") {
619
- const isDownload = otaStatus?.phase === "download"
620
- const totalSteps = otaStatus?.totalSteps ?? 1
621
- const isApkOnlyInstalling = otaStatus?.stepType === "apk" && otaStatus.phase === "install" && totalSteps === 1
622
- const rawPercent = isDownload
623
- ? otaStatus?.stepPercent ?? 0
624
- : totalSteps >= 2
625
- ? otaStatus?.overallPercent ?? 0
626
- : otaStatus?.stepPercent ?? 0
627
- const percent = Math.min(Math.max(rawPercent, mtkInstallStallSimulatedPercent ?? 0, 0), 100)
396
+ if (state.screen === "updating") {
628
397
  return (
629
- <FlowPage colors={colors} icon="download" title={isDownload ? "Downloading..." : "Installing..."}>
630
- {isApkOnlyInstalling ? (
398
+ <FlowPage colors={colors} icon="download" title={state.phase === "download" ? "Downloading..." : "Installing..."}>
399
+ {state.installingApkOnly ? (
631
400
  <ActivityIndicator size="large" color={colors.foreground} />
632
401
  ) : (
633
402
  <>
634
- <PercentText colors={colors} percent={percent} />
403
+ <PercentText colors={colors} percent={state.progress ?? 0} />
635
404
  <View style={[styles.progressTrack, {backgroundColor: colors.border}]}>
636
- <View style={[styles.progressFill, {backgroundColor: colors.primary, width: `${percent}%`}]} />
405
+ <View
406
+ style={[styles.progressFill, {backgroundColor: colors.primary, width: `${state.progress ?? 0}%`}]}
407
+ />
637
408
  </View>
638
409
  </>
639
410
  )}
640
411
  <BodyText colors={colors}>Do not disconnect your glasses</BodyText>
641
- {isVersionChange && otaStatus?.phase === "install" ? (
412
+ {state.versionChange && state.phase === "install" ? (
642
413
  <BodyText colors={colors}>{translate("ota:downgradeDuration")}</BodyText>
643
414
  ) : null}
644
415
  </FlowPage>
645
416
  )
646
417
  }
647
418
 
648
- if (displayState === "restarting") {
419
+ if (state.screen === "restarting") {
649
420
  return (
650
- <FlowPage
651
- actions={
652
- <FlowButton colors={colors} disabled={continueButtonDisabled} label="Continue" onPress={stopAndCheck} />
653
- }
654
- colors={colors}
655
- icon="check"
656
- title="Update Installed"
657
- />
421
+ <FlowPage colors={colors} icon="download" title={translate("ota:restartingGlasses", {deviceName})}>
422
+ <ActivityIndicator size="large" color={colors.foreground} />
423
+ <BodyText colors={colors}>{translate("ota:restartingGlassesMessage")}</BodyText>
424
+ <BodyText colors={colors}>{translate("ota:restartingGlassesAutomatic")}</BodyText>
425
+ </FlowPage>
658
426
  )
659
427
  }
660
428
 
661
- if (displayState === "complete") {
662
- const title = versionChangeConverged
429
+ if (state.screen === "complete") {
430
+ const title = state.versionChangeConverged
663
431
  ? translate("ota:versionChangeComplete")
664
- : isVersionChange
665
- ? translate("ota:versionChangeFirmwarePassComplete")
666
- : "Update complete!"
667
- const message = versionChangeConverged
432
+ : state.versionChange
433
+ ? translate("ota:versionChangeFirmwarePassComplete")
434
+ : "Update complete!"
435
+ const message = state.versionChangeConverged
668
436
  ? translate("ota:versionChangeCompleteMessage")
669
- : isVersionChange
670
- ? translate("ota:versionChangeFirmwarePassCompleteMessage")
671
- : "Your glasses are up to date."
437
+ : state.versionChange
438
+ ? translate("ota:versionChangeFirmwarePassCompleteMessage")
439
+ : "Your glasses are up to date."
672
440
  return (
673
441
  <FlowPage
674
442
  actions={
675
443
  <FlowButton
676
444
  colors={colors}
677
- label={isVersionChange && !versionChangeConverged ? "Continue" : "Done"}
678
- onPress={finishAndCheck}
445
+ label={state.versionChange && !state.versionChangeConverged ? "Continue" : "Done"}
446
+ onPress={controller.finish}
679
447
  />
680
448
  }
681
449
  colors={colors}
450
+ contentAlignment={state.changelogs.length > 0 ? "top" : "center"}
682
451
  icon="check"
683
452
  title={title}>
684
453
  <BodyText colors={colors}>{message}</BodyText>
454
+ {state.releaseTransition ? (
455
+ <BodyText colors={colors}>
456
+ {translate("ota:updatedToVersion", {version: state.releaseTransition.toVersion})}
457
+ </BodyText>
458
+ ) : null}
459
+ <ChangelogList changelogs={state.changelogs} colors={colors} title={translate("ota:whatsNew")} />
685
460
  </FlowPage>
686
461
  )
687
462
  }
688
463
 
689
- if (displayState === "failed") {
690
- const requiresGlassesReboot = shouldRequireGlassesRebootForBesFailure(otaStatus, otaProgress, errorMsg)
691
- const displayedError = requiresGlassesReboot
692
- ? BES_INSTALL_RESTART_MESSAGE
693
- : errorMsg || getOtaErrorMessage(otaStatus?.error)
694
- const showChangeWifi = shouldShowChangeWifiForOtaDownloadFailure(otaStatus, otaProgress, errorMsg)
464
+ if (state.screen === "failed") {
695
465
  return (
696
466
  <FlowPage
697
467
  actions={
698
468
  <>
699
469
  <FlowButton
700
470
  colors={colors}
701
- label={requiresGlassesReboot ? "Done" : "Retry"}
702
- onPress={requiresGlassesReboot ? stopAndCheck : retryInstall}
471
+ label={state.canRetry ? "Retry" : "Done"}
472
+ onPress={state.canRetry ? controller.retryInstall : controller.finish}
703
473
  />
704
- {showChangeWifi ? (
705
- <FlowButton
706
- colors={colors}
707
- label="Change WiFi"
708
- onPress={() => {
709
- stopOtaAutoChain()
710
- onOpenWifiSetup()
711
- }}
712
- secondary
713
- />
474
+ {state.canOpenWifiSetup ? (
475
+ <FlowButton colors={colors} label="Change WiFi" onPress={controller.openWifiSetup} secondary />
714
476
  ) : null}
715
477
  </>
716
478
  }
717
479
  colors={colors}
718
480
  icon="alert"
719
481
  title="Update Failed">
720
- <BodyText colors={colors}>{displayedError}</BodyText>
482
+ <BodyText colors={colors}>{state.error?.message}</BodyText>
721
483
  </FlowPage>
722
484
  )
723
485
  }
@@ -726,16 +488,7 @@ function OtaProgressPage({
726
488
  <FlowPage
727
489
  actions={
728
490
  superMode ? (
729
- <FlowButton
730
- colors={colors}
731
- label="Skip (super)"
732
- onPress={() => {
733
- stopOtaAutoChain()
734
- ota.installSession.discard()
735
- onReturnToCheck()
736
- }}
737
- secondary
738
- />
491
+ <FlowButton colors={colors} label="Skip (super)" onPress={controller.discard} secondary />
739
492
  ) : undefined
740
493
  }
741
494
  colors={colors}
@@ -751,14 +504,15 @@ type FlowPageProps = {
751
504
  actions?: React.ReactNode
752
505
  children?: React.ReactNode
753
506
  colors: MentraLiveOtaFlowTheme
507
+ contentAlignment?: "center" | "top"
754
508
  icon: "alert" | "bluetooth" | "check" | "download" | "settings"
755
509
  title: string
756
510
  }
757
511
 
758
- function FlowPage({actions, children, colors, icon, title}: FlowPageProps) {
512
+ function FlowPage({actions, children, colors, contentAlignment = "center", icon, title}: FlowPageProps) {
759
513
  return (
760
514
  <View style={styles.page} testID="mentra-live-ota-flow">
761
- <View style={styles.centerContent}>
515
+ <View style={[styles.centerContent, contentAlignment === "top" && styles.topContent]}>
762
516
  <FlowIcon colors={colors} name={icon} />
763
517
  <Text style={[styles.title, {color: colors.foreground}]}>{title}</Text>
764
518
  {children}
@@ -776,6 +530,132 @@ function PercentText({colors, percent}: {colors: MentraLiveOtaFlowTheme; percent
776
530
  return <Text style={[styles.percent, {color: colors.primary}]}>{Math.round(percent)}%</Text>
777
531
  }
778
532
 
533
+ function ChangelogMarkdown({colors, markdown}: {colors: MentraLiveOtaFlowTheme; markdown: string}) {
534
+ const markdownStyles = useMemo<MarkedStyles>(
535
+ () => ({
536
+ blockquote: {
537
+ borderLeftColor: colors.primary,
538
+ borderLeftWidth: 3,
539
+ marginVertical: 2,
540
+ opacity: 1,
541
+ paddingLeft: 12,
542
+ },
543
+ code: {
544
+ backgroundColor: colors.background,
545
+ borderColor: colors.border,
546
+ borderRadius: 8,
547
+ borderWidth: 1,
548
+ padding: 12,
549
+ },
550
+ codespan: {
551
+ backgroundColor: colors.border,
552
+ color: colors.foreground,
553
+ fontFamily: "monospace",
554
+ fontSize: 13,
555
+ fontStyle: "normal",
556
+ fontWeight: "400",
557
+ },
558
+ em: {color: colors.textDim, fontSize: 14, lineHeight: 20},
559
+ h1: {
560
+ borderBottomWidth: 0,
561
+ color: colors.foreground,
562
+ fontSize: 18,
563
+ fontWeight: "700",
564
+ lineHeight: 24,
565
+ marginVertical: 0,
566
+ paddingBottom: 0,
567
+ },
568
+ h2: {
569
+ borderBottomWidth: 0,
570
+ color: colors.foreground,
571
+ fontSize: 17,
572
+ fontWeight: "700",
573
+ lineHeight: 23,
574
+ marginVertical: 0,
575
+ paddingBottom: 0,
576
+ },
577
+ h3: {color: colors.foreground, fontSize: 16, fontWeight: "700", lineHeight: 22, marginVertical: 0},
578
+ h4: {color: colors.foreground, fontSize: 15, fontWeight: "700", lineHeight: 21, marginVertical: 0},
579
+ h5: {color: colors.foreground, fontSize: 14, fontWeight: "700", lineHeight: 20, marginVertical: 0},
580
+ h6: {color: colors.textDim, fontSize: 14, fontWeight: "600", lineHeight: 20, marginVertical: 0},
581
+ hr: {borderBottomColor: colors.border, marginVertical: 2},
582
+ image: {borderRadius: 8},
583
+ li: {color: colors.textDim, fontSize: 14, lineHeight: 20},
584
+ link: {
585
+ color: colors.primary,
586
+ fontSize: 14,
587
+ fontStyle: "normal",
588
+ lineHeight: 20,
589
+ textDecorationLine: "underline",
590
+ },
591
+ paragraph: {paddingVertical: 0},
592
+ strikethrough: {color: colors.textDim, fontSize: 14, lineHeight: 20},
593
+ strong: {color: colors.foreground, fontSize: 14, fontWeight: "700", lineHeight: 20},
594
+ table: {borderColor: colors.border, borderRadius: 8},
595
+ tableCell: {padding: 8},
596
+ text: {color: colors.textDim, fontSize: 14, lineHeight: 20},
597
+ }),
598
+ [colors],
599
+ )
600
+ const markdownTheme = useMemo<NonNullable<useMarkdownHookOptions["theme"]>>(
601
+ () => ({
602
+ colors: {
603
+ background: colors.background,
604
+ border: colors.border,
605
+ code: colors.border,
606
+ link: colors.primary,
607
+ text: colors.textDim,
608
+ },
609
+ }),
610
+ [colors],
611
+ )
612
+ const elements = useMarkdown(markdown, {colorScheme: "light", styles: markdownStyles, theme: markdownTheme})
613
+
614
+ return (
615
+ <View style={styles.changelogMarkdownContent} testID="ota-changelog-markdown">
616
+ {elements}
617
+ </View>
618
+ )
619
+ }
620
+
621
+ export function ChangelogList({
622
+ changelogs,
623
+ colors,
624
+ title,
625
+ }: {
626
+ changelogs: MentraLiveOtaController["state"]["changelogs"]
627
+ colors: MentraLiveOtaFlowTheme
628
+ title: string
629
+ }) {
630
+ if (changelogs.length === 0) return null
631
+ return (
632
+ <View style={[styles.changelogCard, {borderColor: colors.border}]} testID="ota-changelog-card">
633
+ <Text style={[styles.changelogTitle, {color: colors.foreground}]}>{title}</Text>
634
+ <ScrollView
635
+ contentContainerStyle={styles.changelogContent}
636
+ nestedScrollEnabled
637
+ showsVerticalScrollIndicator
638
+ style={styles.changelogList}
639
+ testID="ota-changelog-scroll">
640
+ {changelogs.map((entry, index) => (
641
+ <View
642
+ key={entry.version}
643
+ style={[
644
+ styles.changelogEntry,
645
+ index > 0 && styles.changelogEntryDivider,
646
+ index > 0 && {borderTopColor: colors.border},
647
+ ]}>
648
+ <Text selectable style={[styles.changelogVersion, {color: colors.foreground}]}>
649
+ {entry.version}
650
+ </Text>
651
+ <ChangelogMarkdown colors={colors} markdown={entry.markdown} />
652
+ </View>
653
+ ))}
654
+ </ScrollView>
655
+ </View>
656
+ )
657
+ }
658
+
779
659
  function FlowButton({
780
660
  colors,
781
661
  disabled = false,
@@ -797,13 +677,13 @@ function FlowButton({
797
677
  style={({pressed}) => [
798
678
  styles.button,
799
679
  {
800
- backgroundColor: secondary ? colors.background : colors.primary,
801
- borderColor: secondary ? colors.border : colors.primary,
680
+ backgroundColor: secondary ? colors.background : colors.foreground,
681
+ borderColor: secondary ? colors.border : colors.foreground,
802
682
  opacity: disabled ? 0.45 : pressed ? 0.75 : 1,
803
683
  },
804
684
  ]}
805
685
  testID={`button-${label}`}>
806
- <Text style={[styles.buttonText, {color: secondary ? colors.foreground : colors.primaryText}]}>{label}</Text>
686
+ <Text style={[styles.buttonText, {color: secondary ? colors.foreground : colors.background}]}>{label}</Text>
807
687
  </Pressable>
808
688
  )
809
689
  }
@@ -836,6 +716,7 @@ const styles = StyleSheet.create({
836
716
  },
837
717
  page: {flex: 1, paddingBottom: 24, paddingHorizontal: 24},
838
718
  centerContent: {alignItems: "center", flex: 1, gap: 16, justifyContent: "center"},
719
+ topContent: {justifyContent: "flex-start", paddingTop: 12},
839
720
  actionSpacer: {height: 48},
840
721
  actions: {gap: 12},
841
722
  icon: {fontSize: 64, fontWeight: "500", lineHeight: 72, textAlign: "center"},
@@ -844,13 +725,30 @@ const styles = StyleSheet.create({
844
725
  percent: {fontSize: 30, fontVariant: ["tabular-nums"], fontWeight: "700"},
845
726
  progressTrack: {borderRadius: 4, height: 8, maxWidth: 420, overflow: "hidden", width: "100%"},
846
727
  progressFill: {borderRadius: 4, height: 8},
728
+ changelogCard: {
729
+ borderRadius: 16,
730
+ borderWidth: 1,
731
+ flexShrink: 1,
732
+ gap: 12,
733
+ maxHeight: 300,
734
+ maxWidth: 420,
735
+ padding: 16,
736
+ width: "100%",
737
+ },
738
+ changelogTitle: {fontSize: 16, fontWeight: "700"},
739
+ changelogList: {flexShrink: 1, maxHeight: 232, width: "100%"},
740
+ changelogContent: {gap: 20, paddingBottom: 2},
741
+ changelogEntry: {gap: 8},
742
+ changelogEntryDivider: {borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 20},
743
+ changelogVersion: {fontSize: 14, fontWeight: "600"},
744
+ changelogMarkdownContent: {gap: 10},
847
745
  button: {
848
746
  alignItems: "center",
849
- borderRadius: 10,
747
+ borderRadius: 50,
850
748
  borderWidth: 1,
851
749
  justifyContent: "center",
852
- minHeight: 48,
750
+ minHeight: 44,
853
751
  paddingHorizontal: 20,
854
752
  },
855
- buttonText: {fontSize: 15, fontWeight: "700"},
753
+ buttonText: {fontSize: 14, fontWeight: "500"},
856
754
  })