@mentra/engine 3.1.0-dev.8 → 3.1.0-dev.80

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 (103) 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 +10 -6
  9. package/build/facades/ota.d.ts.map +1 -1
  10. package/build/facades/ota.js +7 -4
  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 +1 -1
  19. package/build/index.d.ts.map +1 -1
  20. package/build/index.js.map +1 -1
  21. package/build/react/MentraLiveOtaFlow.d.ts +8 -2
  22. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -1
  23. package/build/react/MentraLiveOtaFlow.js +249 -354
  24. package/build/react/MentraLiveOtaFlow.js.map +1 -1
  25. package/build/react/index.d.ts +1 -0
  26. package/build/react/index.d.ts.map +1 -1
  27. package/build/react/index.js +1 -0
  28. package/build/react/index.js.map +1 -1
  29. package/build/react/useMentraLiveOta.d.ts +84 -0
  30. package/build/react/useMentraLiveOta.d.ts.map +1 -0
  31. package/build/react/useMentraLiveOta.js +616 -0
  32. package/build/react/useMentraLiveOta.js.map +1 -0
  33. package/build/services/BlobStore.d.ts +2 -0
  34. package/build/services/BlobStore.d.ts.map +1 -1
  35. package/build/services/BlobStore.js +63 -10
  36. package/build/services/BlobStore.js.map +1 -1
  37. package/build/services/GlassesStatusProjection.d.ts +15 -2
  38. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  39. package/build/services/GlassesStatusProjection.js +3 -3
  40. package/build/services/GlassesStatusProjection.js.map +1 -1
  41. package/build/services/HotspotOtaTransport.d.ts.map +1 -1
  42. package/build/services/HotspotOtaTransport.js +8 -7
  43. package/build/services/HotspotOtaTransport.js.map +1 -1
  44. package/build/services/OtaAutoChain.d.ts +9 -2
  45. package/build/services/OtaAutoChain.d.ts.map +1 -1
  46. package/build/services/OtaAutoChain.js +10 -2
  47. package/build/services/OtaAutoChain.js.map +1 -1
  48. package/build/services/OtaInstallCoordinator.d.ts +24 -3
  49. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  50. package/build/services/OtaInstallCoordinator.js +14 -12
  51. package/build/services/OtaInstallCoordinator.js.map +1 -1
  52. package/build/services/OtaService.d.ts.map +1 -1
  53. package/build/services/OtaService.js +1 -1
  54. package/build/services/OtaService.js.map +1 -1
  55. package/build/services/OtaUpdateCheckService.d.ts +4 -1
  56. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  57. package/build/services/OtaUpdateCheckService.js +21 -5
  58. package/build/services/OtaUpdateCheckService.js.map +1 -1
  59. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  60. package/build/services/asg/localNetworkTransport.js +13 -12
  61. package/build/services/asg/localNetworkTransport.js.map +1 -1
  62. package/build/services/glassesClockSync.js +1 -1
  63. package/build/services/glassesClockSync.js.map +1 -1
  64. package/build/services/otaReleaseVersion.d.ts +10 -0
  65. package/build/services/otaReleaseVersion.d.ts.map +1 -0
  66. package/build/services/otaReleaseVersion.js +14 -0
  67. package/build/services/otaReleaseVersion.js.map +1 -0
  68. package/build/stores/appVisibility.d.ts +3 -0
  69. package/build/stores/appVisibility.d.ts.map +1 -0
  70. package/build/stores/appVisibility.js +3 -0
  71. package/build/stores/appVisibility.js.map +1 -0
  72. package/build/stores/apps.d.ts.map +1 -1
  73. package/build/stores/apps.js +7 -1
  74. package/build/stores/apps.js.map +1 -1
  75. package/build/stores/core.d.ts +3 -3
  76. package/build/stores/core.d.ts.map +1 -1
  77. package/build/stores/core.js.map +1 -1
  78. package/build/stores/glasses.d.ts +10 -4
  79. package/build/stores/glasses.d.ts.map +1 -1
  80. package/build/stores/glasses.js.map +1 -1
  81. package/package.json +13 -6
  82. package/src/bluetooth-sdk/ota-transport.ts +1 -0
  83. package/src/facades/ota.ts +17 -5
  84. package/src/facades/pairing.ts +88 -12
  85. package/src/generated/releaseMetadata.ts +5 -5
  86. package/src/index.ts +1 -0
  87. package/src/react/MentraLiveOtaFlow.tsx +370 -473
  88. package/src/react/index.ts +14 -0
  89. package/src/react/useMentraLiveOta.ts +781 -0
  90. package/src/services/BlobStore.ts +57 -10
  91. package/src/services/GlassesStatusProjection.ts +7 -6
  92. package/src/services/HotspotOtaTransport.ts +8 -7
  93. package/src/services/OtaAutoChain.ts +26 -2
  94. package/src/services/OtaInstallCoordinator.ts +16 -13
  95. package/src/services/OtaService.ts +1 -2
  96. package/src/services/OtaUpdateCheckService.ts +26 -8
  97. package/src/services/asg/localNetworkTransport.ts +20 -19
  98. package/src/services/glassesClockSync.ts +1 -1
  99. package/src/services/otaReleaseVersion.ts +22 -0
  100. package/src/stores/appVisibility.ts +3 -0
  101. package/src/stores/apps.ts +7 -1
  102. package/src/stores/core.ts +4 -4
  103. 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":
@@ -133,29 +143,13 @@ export function MentraLiveOtaFlow({
133
143
  translate = defaultTranslate,
134
144
  }: MentraLiveOtaFlowProps) {
135
145
  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
- }, [])
146
+ const controller = useMentraLiveOta({
147
+ initialPage,
148
+ initializeRuntime,
149
+ onFinished,
150
+ onFirmwareRestartingChange,
151
+ onOpenWifiSetup,
152
+ })
159
153
 
160
154
  return (
161
155
  <SafeAreaView style={[styles.safeArea, {backgroundColor: colors.background}, style]}>
@@ -163,300 +157,139 @@ export function MentraLiveOtaFlow({
163
157
  <View />
164
158
  <MentraMark color={colors.primary} />
165
159
  </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
- )}
160
+ <OtaFlowContent
161
+ allowDevSkip={allowDevSkip}
162
+ colors={colors}
163
+ controller={controller}
164
+ deviceName={deviceName}
165
+ superMode={superMode}
166
+ translate={translate}
167
+ />
191
168
  </SafeAreaView>
192
169
  )
193
170
  }
194
171
 
195
- type CheckPageProps = {
172
+ function OtaFlowContent({
173
+ allowDevSkip,
174
+ colors,
175
+ controller,
176
+ deviceName,
177
+ superMode,
178
+ translate,
179
+ }: {
196
180
  allowDevSkip: boolean
197
181
  colors: MentraLiveOtaFlowTheme
182
+ controller: MentraLiveOtaController
198
183
  deviceName: string
199
- generation: number
200
- onFinished: () => void
201
- onOpenWifiSetup: () => void
202
- onStartProgress: () => void
184
+ superMode: boolean
203
185
  translate: MentraLiveOtaFlowTranslate
204
- }
186
+ }) {
187
+ const {state} = controller
205
188
 
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
- }
189
+ if (state.screen === "initializing") {
190
+ return (
191
+ <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
192
+ <ActivityIndicator size="large" color={colors.foreground} />
193
+ </FlowPage>
194
+ )
195
+ }
282
196
 
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)
197
+ if (state.screen === "checking") {
198
+ return (
199
+ <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
200
+ <BodyText colors={colors}>{translate("ota:checkingForUpdatesMessage")}</BodyText>
201
+ <ActivityIndicator size="large" color={colors.foreground} />
202
+ </FlowPage>
203
+ )
394
204
  }
395
205
 
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()
206
+ if (state.screen === "battery_required") {
207
+ return (
208
+ <FlowPage
209
+ actions={
210
+ <>
211
+ <FlowButton colors={colors} disabled label={translate("ota:updateNow")} onPress={controller.install} />
212
+ {state.canDismiss ? (
213
+ <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={controller.finish} secondary />
214
+ ) : null}
215
+ </>
216
+ }
217
+ colors={colors}
218
+ icon="alert"
219
+ title={translate("ota:batteryRequiredTitle", {deviceName})}>
220
+ <BodyText colors={colors}>
221
+ {translate("ota:batteryRequiredMessage", {
222
+ batteryLevel: String(state.batteryLevel),
223
+ deviceName,
224
+ minimumBatteryLevel: String(MINIMUM_OTA_BATTERY_LEVEL),
225
+ })}
226
+ </BodyText>
227
+ <BodyText colors={colors}>{translate("ota:batteryRequiredLiveUpdate")}</BodyText>
228
+ </FlowPage>
229
+ )
414
230
  }
415
231
 
416
- if (checkState === "checking") {
232
+ if (state.screen === "finishing") {
417
233
  return (
418
- <FlowPage colors={colors} icon="download" title={translate("ota:checkingForUpdates")}>
419
- <BodyText colors={colors}>{translate("ota:checkingForUpdatesMessage")}</BodyText>
234
+ <FlowPage colors={colors} icon="download" title={translate("ota:finishingUpdate")}>
235
+ <BodyText colors={colors}>{translate("ota:checkingAdditionalUpdates")}</BodyText>
420
236
  <ActivityIndicator size="large" color={colors.foreground} />
421
237
  </FlowPage>
422
238
  )
423
239
  }
424
240
 
425
- if (checkState === "update_available") {
241
+ if (state.screen === "update_available" || state.screen === "wifi_required") {
242
+ const titleKey =
243
+ state.screen === "wifi_required"
244
+ ? "ota:wifiRequiredTitle"
245
+ : state.versionChange
246
+ ? "ota:downgradeAvailable"
247
+ : "ota:updateAvailable"
426
248
  return (
427
249
  <FlowPage
428
250
  colors={colors}
429
251
  icon="download"
430
- title={translate(isDowngradeUpdate ? "ota:downgradeAvailable" : "ota:updateAvailable", {deviceName})}
252
+ title={translate(titleKey, {deviceName})}
431
253
  actions={
432
254
  <>
433
255
  <FlowButton
434
256
  colors={colors}
435
- disabled={!glassesWifiStatusKnown}
436
- label={translate(!glassesWifiStatusKnown || canInstallUpdate ? "ota:updateNow" : "ota:setupWifi")}
437
- onPress={updateNow}
257
+ disabled={!state.wifiStatusKnown}
258
+ label={translate(state.screen === "wifi_required" ? "ota:setupWifi" : "ota:updateNow")}
259
+ onPress={state.screen === "wifi_required" ? controller.openWifiSetup : controller.install}
438
260
  />
439
- {!isUpdateRequired ? (
440
- <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={onFinished} secondary />
261
+ {state.canDismiss ? (
262
+ <FlowButton colors={colors} label={translate("ota:updateLater")} onPress={controller.finish} secondary />
441
263
  ) : null}
442
- {allowDevSkip && isUpdateRequired ? (
443
- <FlowButton colors={colors} label="Skip (dev only)" onPress={onFinished} secondary />
264
+ {allowDevSkip && state.updateRequired ? (
265
+ <FlowButton colors={colors} label="Skip (dev only)" onPress={controller.finish} secondary />
444
266
  ) : null}
445
267
  </>
446
268
  }>
269
+ {state.releaseTransition ? (
270
+ <BodyText colors={colors}>
271
+ {state.releaseTransition.fromVersion
272
+ ? translate("ota:releaseTransition", {
273
+ fromVersion: state.releaseTransition.fromVersion,
274
+ toVersion: state.releaseTransition.toVersion,
275
+ })
276
+ : translate("ota:releaseTransitionUnknown", {toVersion: state.releaseTransition.toVersion})}
277
+ </BodyText>
278
+ ) : null}
447
279
  <BodyText colors={colors}>
448
- {canInstallUpdate
449
- ? translate(isDowngradeUpdate ? "ota:downgradeDescription" : "ota:updateDescription")
450
- : translate("ota:updateConnectWifi", {deviceName})}
280
+ {state.screen === "wifi_required"
281
+ ? translate("ota:updateConnectWifi", {deviceName})
282
+ : translate(state.versionChange ? "ota:downgradeDescription" : "ota:updateDescription")}
451
283
  </BodyText>
284
+ <BodyText colors={colors}>{translate("ota:updateSequenceMessage")}</BodyText>
452
285
  </FlowPage>
453
286
  )
454
287
  }
455
288
 
456
- if (checkState === "dev_build") {
289
+ if (state.screen === "dev_build") {
457
290
  return (
458
291
  <FlowPage
459
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
292
+ actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={controller.finish} />}
460
293
  colors={colors}
461
294
  icon="settings"
462
295
  title={translate("ota:devBuild")}>
@@ -465,22 +298,35 @@ function OtaCheckPage({
465
298
  )
466
299
  }
467
300
 
468
- if (checkState === "no_update") {
301
+ if (state.screen === "up_to_date") {
469
302
  return (
470
303
  <FlowPage
471
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
304
+ actions={
305
+ <FlowButton
306
+ colors={colors}
307
+ label={translate(state.completedUpdate ? "common:done" : "common:continue")}
308
+ onPress={controller.finish}
309
+ />
310
+ }
472
311
  colors={colors}
312
+ contentAlignment={state.changelogs.length > 0 ? "top" : "center"}
473
313
  icon="check"
474
- title={translate("ota:upToDate")}>
314
+ title={translate(state.completedUpdate ? "ota:updateComplete" : "ota:upToDate")}>
475
315
  <BodyText colors={colors}>{translate("ota:noUpdatesAvailable")}</BodyText>
316
+ {state.releaseTransition ? (
317
+ <BodyText colors={colors}>
318
+ {translate("ota:updatedToVersion", {version: state.releaseTransition.toVersion})}
319
+ </BodyText>
320
+ ) : null}
321
+ <ChangelogList changelogs={state.changelogs} colors={colors} title={translate("ota:whatsNew")} />
476
322
  </FlowPage>
477
323
  )
478
324
  }
479
325
 
480
- if (errorKind === "pin_unavailable") {
326
+ if (state.screen === "update_info_unavailable") {
481
327
  return (
482
328
  <FlowPage
483
- actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={onFinished} />}
329
+ actions={<FlowButton colors={colors} label={translate("common:continue")} onPress={controller.finish} />}
484
330
  colors={colors}
485
331
  icon="alert"
486
332
  title={translate("ota:updateInfoUnavailable")}>
@@ -489,104 +335,32 @@ function OtaCheckPage({
489
335
  )
490
336
  }
491
337
 
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()
338
+ if (state.screen === "check_failed") {
339
+ return (
340
+ <FlowPage
341
+ actions={
342
+ <>
343
+ <FlowButton colors={colors} label="Retry" onPress={controller.retryCheck} />
344
+ {allowDevSkip ? (
345
+ <FlowButton colors={colors} label="Skip (dev only)" onPress={controller.finish} secondary />
346
+ ) : null}
347
+ </>
348
+ }
349
+ colors={colors}
350
+ icon="alert"
351
+ title={translate("ota:checkFailed")}>
352
+ <BodyText colors={colors}>{translate("ota:checkFailedMessage")}</BodyText>
353
+ </FlowPage>
354
+ )
581
355
  }
582
356
 
583
- if (versionChangePhase === "restarting" || versionChangePhase === "verifying") {
357
+ if (state.versionChangePhase === "restarting" || state.versionChangePhase === "verifying") {
584
358
  return (
585
359
  <FlowPage
586
360
  colors={colors}
587
361
  icon="download"
588
362
  title={translate(
589
- versionChangePhase === "verifying" ? "ota:versionChangeVerifying" : "ota:versionChangeRestarting",
363
+ state.versionChangePhase === "verifying" ? "ota:versionChangeVerifying" : "ota:versionChangeRestarting",
590
364
  )}>
591
365
  <ActivityIndicator size="large" color={colors.foreground} />
592
366
  <BodyText colors={colors}>{translate("ota:versionChangeKeepNearby")}</BodyText>
@@ -595,19 +369,19 @@ function OtaProgressPage({
595
369
  )
596
370
  }
597
371
 
598
- if (displayState === "starting") {
599
- const hotspotTitle =
600
- hotspotPhase === "downloading"
372
+ if (state.screen === "starting" || state.screen === "preparing_hotspot") {
373
+ const title =
374
+ state.hotspotPhase === "downloading"
601
375
  ? "Downloading update to phone..."
602
- : hotspotPhase === "starting_hotspot"
603
- ? "Starting glasses hotspot..."
604
- : hotspotPhase === "joining_hotspot"
605
- ? "Connecting phone to glasses..."
606
- : "Starting update..."
376
+ : state.hotspotPhase === "starting_hotspot"
377
+ ? "Starting glasses hotspot..."
378
+ : state.hotspotPhase === "joining_hotspot"
379
+ ? "Connecting phone to glasses..."
380
+ : "Starting update..."
607
381
  return (
608
- <FlowPage colors={colors} icon="download" title={hotspotTitle}>
609
- {hotspotPhase === "downloading" && hotspotArtifactPercent !== null ? (
610
- <PercentText colors={colors} percent={hotspotArtifactPercent} />
382
+ <FlowPage colors={colors} icon="download" title={title}>
383
+ {state.hotspotPhase === "downloading" && state.hotspotArtifactPercent !== null ? (
384
+ <PercentText colors={colors} percent={state.hotspotArtifactPercent} />
611
385
  ) : null}
612
386
  <ActivityIndicator size="large" color={colors.foreground} />
613
387
  <BodyText colors={colors}>Do not disconnect your glasses</BodyText>
@@ -615,41 +389,34 @@ function OtaProgressPage({
615
389
  )
616
390
  }
617
391
 
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)
392
+ if (state.screen === "updating") {
628
393
  return (
629
- <FlowPage colors={colors} icon="download" title={isDownload ? "Downloading..." : "Installing..."}>
630
- {isApkOnlyInstalling ? (
394
+ <FlowPage colors={colors} icon="download" title={state.phase === "download" ? "Downloading..." : "Installing..."}>
395
+ {state.installingApkOnly ? (
631
396
  <ActivityIndicator size="large" color={colors.foreground} />
632
397
  ) : (
633
398
  <>
634
- <PercentText colors={colors} percent={percent} />
399
+ <PercentText colors={colors} percent={state.progress ?? 0} />
635
400
  <View style={[styles.progressTrack, {backgroundColor: colors.border}]}>
636
- <View style={[styles.progressFill, {backgroundColor: colors.primary, width: `${percent}%`}]} />
401
+ <View
402
+ style={[styles.progressFill, {backgroundColor: colors.primary, width: `${state.progress ?? 0}%`}]}
403
+ />
637
404
  </View>
638
405
  </>
639
406
  )}
640
407
  <BodyText colors={colors}>Do not disconnect your glasses</BodyText>
641
- {isVersionChange && otaStatus?.phase === "install" ? (
408
+ {state.versionChange && state.phase === "install" ? (
642
409
  <BodyText colors={colors}>{translate("ota:downgradeDuration")}</BodyText>
643
410
  ) : null}
644
411
  </FlowPage>
645
412
  )
646
413
  }
647
414
 
648
- if (displayState === "restarting") {
415
+ if (state.screen === "restarting") {
649
416
  return (
650
417
  <FlowPage
651
418
  actions={
652
- <FlowButton colors={colors} disabled={continueButtonDisabled} label="Continue" onPress={stopAndCheck} />
419
+ <FlowButton colors={colors} disabled={state.continueDisabled} label="Continue" onPress={controller.finish} />
653
420
  }
654
421
  colors={colors}
655
422
  icon="check"
@@ -658,66 +425,60 @@ function OtaProgressPage({
658
425
  )
659
426
  }
660
427
 
661
- if (displayState === "complete") {
662
- const title = versionChangeConverged
428
+ if (state.screen === "complete") {
429
+ const title = state.versionChangeConverged
663
430
  ? translate("ota:versionChangeComplete")
664
- : isVersionChange
665
- ? translate("ota:versionChangeFirmwarePassComplete")
666
- : "Update complete!"
667
- const message = versionChangeConverged
431
+ : state.versionChange
432
+ ? translate("ota:versionChangeFirmwarePassComplete")
433
+ : "Update complete!"
434
+ const message = state.versionChangeConverged
668
435
  ? translate("ota:versionChangeCompleteMessage")
669
- : isVersionChange
670
- ? translate("ota:versionChangeFirmwarePassCompleteMessage")
671
- : "Your glasses are up to date."
436
+ : state.versionChange
437
+ ? translate("ota:versionChangeFirmwarePassCompleteMessage")
438
+ : "Your glasses are up to date."
672
439
  return (
673
440
  <FlowPage
674
441
  actions={
675
442
  <FlowButton
676
443
  colors={colors}
677
- label={isVersionChange && !versionChangeConverged ? "Continue" : "Done"}
678
- onPress={finishAndCheck}
444
+ label={state.versionChange && !state.versionChangeConverged ? "Continue" : "Done"}
445
+ onPress={controller.finish}
679
446
  />
680
447
  }
681
448
  colors={colors}
449
+ contentAlignment={state.changelogs.length > 0 ? "top" : "center"}
682
450
  icon="check"
683
451
  title={title}>
684
452
  <BodyText colors={colors}>{message}</BodyText>
453
+ {state.releaseTransition ? (
454
+ <BodyText colors={colors}>
455
+ {translate("ota:updatedToVersion", {version: state.releaseTransition.toVersion})}
456
+ </BodyText>
457
+ ) : null}
458
+ <ChangelogList changelogs={state.changelogs} colors={colors} title={translate("ota:whatsNew")} />
685
459
  </FlowPage>
686
460
  )
687
461
  }
688
462
 
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)
463
+ if (state.screen === "failed") {
695
464
  return (
696
465
  <FlowPage
697
466
  actions={
698
467
  <>
699
468
  <FlowButton
700
469
  colors={colors}
701
- label={requiresGlassesReboot ? "Done" : "Retry"}
702
- onPress={requiresGlassesReboot ? stopAndCheck : retryInstall}
470
+ label={state.canRetry ? "Retry" : "Done"}
471
+ onPress={state.canRetry ? controller.retryInstall : controller.finish}
703
472
  />
704
- {showChangeWifi ? (
705
- <FlowButton
706
- colors={colors}
707
- label="Change WiFi"
708
- onPress={() => {
709
- stopOtaAutoChain()
710
- onOpenWifiSetup()
711
- }}
712
- secondary
713
- />
473
+ {state.canOpenWifiSetup ? (
474
+ <FlowButton colors={colors} label="Change WiFi" onPress={controller.openWifiSetup} secondary />
714
475
  ) : null}
715
476
  </>
716
477
  }
717
478
  colors={colors}
718
479
  icon="alert"
719
480
  title="Update Failed">
720
- <BodyText colors={colors}>{displayedError}</BodyText>
481
+ <BodyText colors={colors}>{state.error?.message}</BodyText>
721
482
  </FlowPage>
722
483
  )
723
484
  }
@@ -726,16 +487,7 @@ function OtaProgressPage({
726
487
  <FlowPage
727
488
  actions={
728
489
  superMode ? (
729
- <FlowButton
730
- colors={colors}
731
- label="Skip (super)"
732
- onPress={() => {
733
- stopOtaAutoChain()
734
- ota.installSession.discard()
735
- onReturnToCheck()
736
- }}
737
- secondary
738
- />
490
+ <FlowButton colors={colors} label="Skip (super)" onPress={controller.discard} secondary />
739
491
  ) : undefined
740
492
  }
741
493
  colors={colors}
@@ -751,14 +503,15 @@ type FlowPageProps = {
751
503
  actions?: React.ReactNode
752
504
  children?: React.ReactNode
753
505
  colors: MentraLiveOtaFlowTheme
506
+ contentAlignment?: "center" | "top"
754
507
  icon: "alert" | "bluetooth" | "check" | "download" | "settings"
755
508
  title: string
756
509
  }
757
510
 
758
- function FlowPage({actions, children, colors, icon, title}: FlowPageProps) {
511
+ function FlowPage({actions, children, colors, contentAlignment = "center", icon, title}: FlowPageProps) {
759
512
  return (
760
513
  <View style={styles.page} testID="mentra-live-ota-flow">
761
- <View style={styles.centerContent}>
514
+ <View style={[styles.centerContent, contentAlignment === "top" && styles.topContent]}>
762
515
  <FlowIcon colors={colors} name={icon} />
763
516
  <Text style={[styles.title, {color: colors.foreground}]}>{title}</Text>
764
517
  {children}
@@ -776,6 +529,132 @@ function PercentText({colors, percent}: {colors: MentraLiveOtaFlowTheme; percent
776
529
  return <Text style={[styles.percent, {color: colors.primary}]}>{Math.round(percent)}%</Text>
777
530
  }
778
531
 
532
+ function ChangelogMarkdown({colors, markdown}: {colors: MentraLiveOtaFlowTheme; markdown: string}) {
533
+ const markdownStyles = useMemo<MarkedStyles>(
534
+ () => ({
535
+ blockquote: {
536
+ borderLeftColor: colors.primary,
537
+ borderLeftWidth: 3,
538
+ marginVertical: 2,
539
+ opacity: 1,
540
+ paddingLeft: 12,
541
+ },
542
+ code: {
543
+ backgroundColor: colors.background,
544
+ borderColor: colors.border,
545
+ borderRadius: 8,
546
+ borderWidth: 1,
547
+ padding: 12,
548
+ },
549
+ codespan: {
550
+ backgroundColor: colors.border,
551
+ color: colors.foreground,
552
+ fontFamily: "monospace",
553
+ fontSize: 13,
554
+ fontStyle: "normal",
555
+ fontWeight: "400",
556
+ },
557
+ em: {color: colors.textDim, fontSize: 14, lineHeight: 20},
558
+ h1: {
559
+ borderBottomWidth: 0,
560
+ color: colors.foreground,
561
+ fontSize: 18,
562
+ fontWeight: "700",
563
+ lineHeight: 24,
564
+ marginVertical: 0,
565
+ paddingBottom: 0,
566
+ },
567
+ h2: {
568
+ borderBottomWidth: 0,
569
+ color: colors.foreground,
570
+ fontSize: 17,
571
+ fontWeight: "700",
572
+ lineHeight: 23,
573
+ marginVertical: 0,
574
+ paddingBottom: 0,
575
+ },
576
+ h3: {color: colors.foreground, fontSize: 16, fontWeight: "700", lineHeight: 22, marginVertical: 0},
577
+ h4: {color: colors.foreground, fontSize: 15, fontWeight: "700", lineHeight: 21, marginVertical: 0},
578
+ h5: {color: colors.foreground, fontSize: 14, fontWeight: "700", lineHeight: 20, marginVertical: 0},
579
+ h6: {color: colors.textDim, fontSize: 14, fontWeight: "600", lineHeight: 20, marginVertical: 0},
580
+ hr: {borderBottomColor: colors.border, marginVertical: 2},
581
+ image: {borderRadius: 8},
582
+ li: {color: colors.textDim, fontSize: 14, lineHeight: 20},
583
+ link: {
584
+ color: colors.primary,
585
+ fontSize: 14,
586
+ fontStyle: "normal",
587
+ lineHeight: 20,
588
+ textDecorationLine: "underline",
589
+ },
590
+ paragraph: {paddingVertical: 0},
591
+ strikethrough: {color: colors.textDim, fontSize: 14, lineHeight: 20},
592
+ strong: {color: colors.foreground, fontSize: 14, fontWeight: "700", lineHeight: 20},
593
+ table: {borderColor: colors.border, borderRadius: 8},
594
+ tableCell: {padding: 8},
595
+ text: {color: colors.textDim, fontSize: 14, lineHeight: 20},
596
+ }),
597
+ [colors],
598
+ )
599
+ const markdownTheme = useMemo<NonNullable<useMarkdownHookOptions["theme"]>>(
600
+ () => ({
601
+ colors: {
602
+ background: colors.background,
603
+ border: colors.border,
604
+ code: colors.border,
605
+ link: colors.primary,
606
+ text: colors.textDim,
607
+ },
608
+ }),
609
+ [colors],
610
+ )
611
+ const elements = useMarkdown(markdown, {colorScheme: "light", styles: markdownStyles, theme: markdownTheme})
612
+
613
+ return (
614
+ <View style={styles.changelogMarkdownContent} testID="ota-changelog-markdown">
615
+ {elements}
616
+ </View>
617
+ )
618
+ }
619
+
620
+ export function ChangelogList({
621
+ changelogs,
622
+ colors,
623
+ title,
624
+ }: {
625
+ changelogs: MentraLiveOtaController["state"]["changelogs"]
626
+ colors: MentraLiveOtaFlowTheme
627
+ title: string
628
+ }) {
629
+ if (changelogs.length === 0) return null
630
+ return (
631
+ <View style={[styles.changelogCard, {borderColor: colors.border}]} testID="ota-changelog-card">
632
+ <Text style={[styles.changelogTitle, {color: colors.foreground}]}>{title}</Text>
633
+ <ScrollView
634
+ contentContainerStyle={styles.changelogContent}
635
+ nestedScrollEnabled
636
+ showsVerticalScrollIndicator
637
+ style={styles.changelogList}
638
+ testID="ota-changelog-scroll">
639
+ {changelogs.map((entry, index) => (
640
+ <View
641
+ key={entry.version}
642
+ style={[
643
+ styles.changelogEntry,
644
+ index > 0 && styles.changelogEntryDivider,
645
+ index > 0 && {borderTopColor: colors.border},
646
+ ]}>
647
+ <Text selectable style={[styles.changelogVersion, {color: colors.foreground}]}>
648
+ {entry.version}
649
+ </Text>
650
+ <ChangelogMarkdown colors={colors} markdown={entry.markdown} />
651
+ </View>
652
+ ))}
653
+ </ScrollView>
654
+ </View>
655
+ )
656
+ }
657
+
779
658
  function FlowButton({
780
659
  colors,
781
660
  disabled = false,
@@ -797,13 +676,13 @@ function FlowButton({
797
676
  style={({pressed}) => [
798
677
  styles.button,
799
678
  {
800
- backgroundColor: secondary ? colors.background : colors.primary,
801
- borderColor: secondary ? colors.border : colors.primary,
679
+ backgroundColor: secondary ? colors.background : colors.foreground,
680
+ borderColor: secondary ? colors.border : colors.foreground,
802
681
  opacity: disabled ? 0.45 : pressed ? 0.75 : 1,
803
682
  },
804
683
  ]}
805
684
  testID={`button-${label}`}>
806
- <Text style={[styles.buttonText, {color: secondary ? colors.foreground : colors.primaryText}]}>{label}</Text>
685
+ <Text style={[styles.buttonText, {color: secondary ? colors.foreground : colors.background}]}>{label}</Text>
807
686
  </Pressable>
808
687
  )
809
688
  }
@@ -836,6 +715,7 @@ const styles = StyleSheet.create({
836
715
  },
837
716
  page: {flex: 1, paddingBottom: 24, paddingHorizontal: 24},
838
717
  centerContent: {alignItems: "center", flex: 1, gap: 16, justifyContent: "center"},
718
+ topContent: {justifyContent: "flex-start", paddingTop: 12},
839
719
  actionSpacer: {height: 48},
840
720
  actions: {gap: 12},
841
721
  icon: {fontSize: 64, fontWeight: "500", lineHeight: 72, textAlign: "center"},
@@ -844,13 +724,30 @@ const styles = StyleSheet.create({
844
724
  percent: {fontSize: 30, fontVariant: ["tabular-nums"], fontWeight: "700"},
845
725
  progressTrack: {borderRadius: 4, height: 8, maxWidth: 420, overflow: "hidden", width: "100%"},
846
726
  progressFill: {borderRadius: 4, height: 8},
727
+ changelogCard: {
728
+ borderRadius: 16,
729
+ borderWidth: 1,
730
+ flexShrink: 1,
731
+ gap: 12,
732
+ maxHeight: 300,
733
+ maxWidth: 420,
734
+ padding: 16,
735
+ width: "100%",
736
+ },
737
+ changelogTitle: {fontSize: 16, fontWeight: "700"},
738
+ changelogList: {flexShrink: 1, maxHeight: 232, width: "100%"},
739
+ changelogContent: {gap: 20, paddingBottom: 2},
740
+ changelogEntry: {gap: 8},
741
+ changelogEntryDivider: {borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 20},
742
+ changelogVersion: {fontSize: 14, fontWeight: "600"},
743
+ changelogMarkdownContent: {gap: 10},
847
744
  button: {
848
745
  alignItems: "center",
849
- borderRadius: 10,
746
+ borderRadius: 50,
850
747
  borderWidth: 1,
851
748
  justifyContent: "center",
852
- minHeight: 48,
749
+ minHeight: 44,
853
750
  paddingHorizontal: 20,
854
751
  },
855
- buttonText: {fontSize: 15, fontWeight: "700"},
752
+ buttonText: {fontSize: 14, fontWeight: "500"},
856
753
  })