@use-voltra/android 2.3.1 → 2.4.0

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 (97) hide show
  1. package/build/commonjs/index.js +31 -0
  2. package/build/commonjs/index.js.map +1 -1
  3. package/build/commonjs/jsx/primitives.js +36 -0
  4. package/build/commonjs/jsx/primitives.js.map +1 -1
  5. package/build/commonjs/modifiers/createAndroidModifier.js +20 -0
  6. package/build/commonjs/modifiers/createAndroidModifier.js.map +1 -0
  7. package/build/commonjs/modifiers/index.js +188 -0
  8. package/build/commonjs/modifiers/index.js.map +1 -0
  9. package/build/commonjs/ongoing-notification/components.js +7 -1
  10. package/build/commonjs/ongoing-notification/components.js.map +1 -1
  11. package/build/commonjs/ongoing-notification/renderer.js +354 -18
  12. package/build/commonjs/ongoing-notification/renderer.js.map +1 -1
  13. package/build/commonjs/ongoing-notification/types.js +56 -0
  14. package/build/commonjs/ongoing-notification/types.js.map +1 -1
  15. package/build/commonjs/server.js.map +1 -1
  16. package/build/module/index.js +3 -1
  17. package/build/module/index.js.map +1 -1
  18. package/build/module/jsx/primitives.js +1 -0
  19. package/build/module/jsx/primitives.js.map +1 -1
  20. package/build/module/modifiers/createAndroidModifier.js +15 -0
  21. package/build/module/modifiers/createAndroidModifier.js.map +1 -0
  22. package/build/module/modifiers/index.js +169 -0
  23. package/build/module/modifiers/index.js.map +1 -0
  24. package/build/module/ongoing-notification/components.js +6 -0
  25. package/build/module/ongoing-notification/components.js.map +1 -1
  26. package/build/module/ongoing-notification/renderer.js +354 -18
  27. package/build/module/ongoing-notification/renderer.js.map +1 -1
  28. package/build/module/ongoing-notification/types.js +55 -1
  29. package/build/module/ongoing-notification/types.js.map +1 -1
  30. package/build/module/server.js.map +1 -1
  31. package/build/typescript/commonjs/index.d.ts +4 -3
  32. package/build/typescript/commonjs/index.d.ts.map +1 -1
  33. package/build/typescript/commonjs/jsx/baseProps.d.ts +8 -0
  34. package/build/typescript/commonjs/jsx/baseProps.d.ts.map +1 -1
  35. package/build/typescript/commonjs/jsx/primitives.d.ts +1 -0
  36. package/build/typescript/commonjs/jsx/primitives.d.ts.map +1 -1
  37. package/build/typescript/commonjs/modifiers/createAndroidModifier.d.ts +13 -0
  38. package/build/typescript/commonjs/modifiers/createAndroidModifier.d.ts.map +1 -0
  39. package/build/typescript/commonjs/modifiers/index.d.ts +171 -0
  40. package/build/typescript/commonjs/modifiers/index.d.ts.map +1 -0
  41. package/build/typescript/commonjs/ongoing-notification/components.d.ts +8 -2
  42. package/build/typescript/commonjs/ongoing-notification/components.d.ts.map +1 -1
  43. package/build/typescript/commonjs/ongoing-notification/renderer.d.ts.map +1 -1
  44. package/build/typescript/commonjs/ongoing-notification/types.d.ts +281 -3
  45. package/build/typescript/commonjs/ongoing-notification/types.d.ts.map +1 -1
  46. package/build/typescript/commonjs/server.d.ts +1 -1
  47. package/build/typescript/commonjs/server.d.ts.map +1 -1
  48. package/build/typescript/commonjs/types.d.ts +1 -81
  49. package/build/typescript/commonjs/types.d.ts.map +1 -1
  50. package/build/typescript/module/index.d.ts +4 -3
  51. package/build/typescript/module/index.d.ts.map +1 -1
  52. package/build/typescript/module/jsx/baseProps.d.ts +8 -0
  53. package/build/typescript/module/jsx/baseProps.d.ts.map +1 -1
  54. package/build/typescript/module/jsx/primitives.d.ts +1 -0
  55. package/build/typescript/module/jsx/primitives.d.ts.map +1 -1
  56. package/build/typescript/module/modifiers/createAndroidModifier.d.ts +13 -0
  57. package/build/typescript/module/modifiers/createAndroidModifier.d.ts.map +1 -0
  58. package/build/typescript/module/modifiers/index.d.ts +171 -0
  59. package/build/typescript/module/modifiers/index.d.ts.map +1 -0
  60. package/build/typescript/module/ongoing-notification/components.d.ts +8 -2
  61. package/build/typescript/module/ongoing-notification/components.d.ts.map +1 -1
  62. package/build/typescript/module/ongoing-notification/renderer.d.ts.map +1 -1
  63. package/build/typescript/module/ongoing-notification/types.d.ts +281 -3
  64. package/build/typescript/module/ongoing-notification/types.d.ts.map +1 -1
  65. package/build/typescript/module/server.d.ts +1 -1
  66. package/build/typescript/module/server.d.ts.map +1 -1
  67. package/build/typescript/module/types.d.ts +1 -81
  68. package/build/typescript/module/types.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/index.ts +36 -2
  71. package/src/jsx/baseProps.tsx +8 -0
  72. package/src/jsx/primitives.ts +1 -0
  73. package/src/modifiers/createAndroidModifier.ts +21 -0
  74. package/src/modifiers/index.ts +166 -0
  75. package/src/ongoing-notification/components.tsx +22 -1
  76. package/src/ongoing-notification/renderer.ts +451 -18
  77. package/src/ongoing-notification/types.ts +298 -4
  78. package/src/server.ts +15 -0
  79. package/src/types.ts +17 -97
  80. package/build/commonjs/preload-utils.js +0 -55
  81. package/build/commonjs/preload-utils.js.map +0 -1
  82. package/build/commonjs/utils/index.js +0 -2
  83. package/build/commonjs/utils/index.js.map +0 -1
  84. package/build/module/preload-utils.js +0 -51
  85. package/build/module/preload-utils.js.map +0 -1
  86. package/build/module/utils/index.js +0 -2
  87. package/build/module/utils/index.js.map +0 -1
  88. package/build/typescript/commonjs/preload-utils.d.ts +0 -8
  89. package/build/typescript/commonjs/preload-utils.d.ts.map +0 -1
  90. package/build/typescript/commonjs/utils/index.d.ts +0 -1
  91. package/build/typescript/commonjs/utils/index.d.ts.map +0 -1
  92. package/build/typescript/module/preload-utils.d.ts +0 -8
  93. package/build/typescript/module/preload-utils.d.ts.map +0 -1
  94. package/build/typescript/module/utils/index.d.ts +0 -1
  95. package/build/typescript/module/utils/index.d.ts.map +0 -1
  96. package/src/preload-utils.ts +0 -78
  97. package/src/utils/index.ts +0 -0
@@ -6,20 +6,48 @@ import { ANDROID_ONGOING_NOTIFICATION_COMPONENT_TAG } from './components.js'
6
6
  import type {
7
7
  AndroidOngoingNotificationActionPayload,
8
8
  AndroidOngoingNotificationActionProps,
9
+ AndroidOngoingNotificationBigPicturePayload,
10
+ AndroidOngoingNotificationBigPictureProps,
9
11
  AndroidOngoingNotificationBigTextPayload,
10
12
  AndroidOngoingNotificationBigTextProps,
13
+ AndroidOngoingNotificationCommonDisplayProps,
11
14
  AndroidOngoingNotificationContent,
15
+ AndroidOngoingNotificationInboxPayload,
16
+ AndroidOngoingNotificationInboxProps,
17
+ AndroidOngoingNotificationMetricPayload,
18
+ AndroidOngoingNotificationMetricProps,
19
+ AndroidOngoingNotificationMetricSemanticStyle,
20
+ AndroidOngoingNotificationMetricValuePayload,
12
21
  AndroidOngoingNotificationPayload,
13
22
  AndroidOngoingNotificationProgressPayload,
14
23
  AndroidOngoingNotificationProgressPoint,
15
24
  AndroidOngoingNotificationProgressProps,
16
25
  AndroidOngoingNotificationProgressSegment,
26
+ AndroidOngoingNotificationPublicVersion,
17
27
  } from './types.js'
18
28
 
19
29
  void getAndroidComponentId
20
30
 
21
31
  const PAYLOAD_VERSION = 1 as const
22
32
 
33
+ /**
34
+ * InboxStyle only renders six lines, so more than that is rejected at render time instead of being
35
+ * silently truncated. Kept in sync with MAX_INBOX_LINES in the Android client.
36
+ */
37
+ const MAX_INBOX_LINES = 6
38
+
39
+ /**
40
+ * Size of an inline picture that is still reasonable to hand to a native post. Push providers cap
41
+ * data payloads at a few kilobytes (FCM allows 4096 bytes), so no inline picture travels through a
42
+ * push message. Warns instead of failing: local starts and self-hosted transports can legitimately
43
+ * carry more. Compared against the decoded size, because a base64 string is four characters per
44
+ * three bytes and its length flatters the image.
45
+ */
46
+ const MAX_INLINE_PICTURE_BYTES = 256 * 1024
47
+
48
+ /** Bytes a base64 string holds once decoded. Padding leaves this at most two bytes high. */
49
+ const decodedBase64Bytes = (base64: string): number => Math.ceil((base64.length * 3) / 4)
50
+
23
51
  const flattenChildren = (node: ReactNode): ReactNode[] => {
24
52
  if (node === null || node === undefined || typeof node === 'boolean') {
25
53
  return []
@@ -123,6 +151,16 @@ const assertOptionalImageSource = (value: unknown, propName: string): ImageSourc
123
151
  return value
124
152
  }
125
153
 
154
+ const assertRequiredImageSource = (value: unknown, propName: string): ImageSource => {
155
+ const source = assertOptionalImageSource(value, propName)
156
+
157
+ if (source === undefined) {
158
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" is required.`)
159
+ }
160
+
161
+ return source
162
+ }
163
+
126
164
  const assertBoolean = (value: unknown, propName: string): boolean | undefined => {
127
165
  if (value === undefined) {
128
166
  return undefined
@@ -211,15 +249,45 @@ const normalizeProgressPoints = (value: unknown): AndroidOngoingNotificationProg
211
249
  })
212
250
  }
213
251
 
214
- const normalizeWhen = (value: unknown): number | undefined => {
215
- if (value === undefined) {
216
- return undefined
252
+ const normalizeInboxLines = (value: unknown): string[] => {
253
+ if (!Array.isArray(value)) {
254
+ throw new Error('[Voltra] [Android] Ongoing notification prop "lines" must be an array of non-empty strings.')
255
+ }
256
+
257
+ if (value.length === 0) {
258
+ throw new Error('[Voltra] [Android] Ongoing notification prop "lines" must contain at least one line.')
259
+ }
260
+
261
+ if (value.length > MAX_INBOX_LINES) {
262
+ throw new Error(
263
+ `[Voltra] [Android] Ongoing notification prop "lines" must contain at most ${MAX_INBOX_LINES} lines, got ${value.length}.`
264
+ )
265
+ }
266
+
267
+ return value.map((line, index) => assertString(line, `lines[${index}]`))
268
+ }
269
+
270
+ const warnInlinePicture = (picture: ImageSource): void => {
271
+ const base64 = 'base64' in picture ? picture.base64 : undefined
272
+
273
+ if (base64 === undefined) {
274
+ return
275
+ }
276
+
277
+ const pictureBytes = decodedBase64Bytes(base64)
278
+
279
+ if (pictureBytes > MAX_INLINE_PICTURE_BYTES) {
280
+ console.warn(
281
+ `[Voltra] [Android] Ongoing notification prop "picture" carries a ${pictureBytes}-byte image, which exceeds ${MAX_INLINE_PICTURE_BYTES} bytes. Inline pictures cannot travel through push messages: preload the image and reference it with assetName instead.`
282
+ )
217
283
  }
284
+ }
218
285
 
286
+ const normalizeEpoch = (value: unknown, propName: string): number => {
219
287
  if (value instanceof Date) {
220
288
  const timestamp = value.getTime()
221
289
  if (!Number.isFinite(timestamp)) {
222
- throw new Error('[Voltra] [Android] Ongoing notification prop "when" must be a valid Date or timestamp.')
290
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a valid Date or timestamp.`)
223
291
  }
224
292
 
225
293
  return timestamp
@@ -229,7 +297,66 @@ const normalizeWhen = (value: unknown): number | undefined => {
229
297
  return value
230
298
  }
231
299
 
232
- throw new Error('[Voltra] [Android] Ongoing notification prop "when" must be a valid Date or timestamp.')
300
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a valid Date or timestamp.`)
301
+ }
302
+
303
+ const normalizeWhen = (value: unknown): number | undefined => {
304
+ if (value === undefined) {
305
+ return undefined
306
+ }
307
+
308
+ return normalizeEpoch(value, 'when')
309
+ }
310
+
311
+ // `true` keeps meaning "count up" for payloads and props written before the
312
+ // chip gained a direction; only 'countDown' sets chronometerCountDown, and a
313
+ // countdown without `when` has nothing to count down to.
314
+ const normalizeChronometer = (
315
+ value: unknown,
316
+ when: number | undefined
317
+ ): { chronometer?: boolean; chronometerCountDown?: boolean } => {
318
+ if (value === undefined) {
319
+ return {}
320
+ }
321
+
322
+ if (typeof value === 'boolean') {
323
+ return { chronometer: value }
324
+ }
325
+
326
+ if (value === 'countUp') {
327
+ return { chronometer: true }
328
+ }
329
+
330
+ if (value === 'countDown') {
331
+ if (when === undefined) {
332
+ throw new Error(
333
+ '[Voltra] [Android] Ongoing notification prop "chronometer" set to "countDown" requires the "when" prop.'
334
+ )
335
+ }
336
+
337
+ return { chronometer: true, chronometerCountDown: true }
338
+ }
339
+
340
+ throw new Error(
341
+ '[Voltra] [Android] Ongoing notification prop "chronometer" must be a boolean, "countUp" or "countDown".'
342
+ )
343
+ }
344
+
345
+ const normalizePublicVersion = (value: unknown): AndroidOngoingNotificationPublicVersion | undefined => {
346
+ if (value === undefined) {
347
+ return undefined
348
+ }
349
+
350
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
351
+ throw new Error('[Voltra] [Android] Ongoing notification prop "publicVersion" must be an object with a title.')
352
+ }
353
+
354
+ const { title, text } = value as { title?: unknown; text?: unknown }
355
+
356
+ return {
357
+ title: assertString(title, 'publicVersion.title'),
358
+ text: assertOptionalString(text, 'publicVersion.text'),
359
+ }
233
360
  }
234
361
 
235
362
  const getElementKind = (element: ReactElement<Record<string, unknown>>) => {
@@ -250,6 +377,33 @@ const normalizeActionPayload = (
250
377
  }
251
378
  }
252
379
 
380
+ const normalizeCommonDisplayFields = (
381
+ props: AndroidOngoingNotificationCommonDisplayProps & { largeIcon?: ImageSource }
382
+ ): {
383
+ title?: string
384
+ subText?: string
385
+ shortCriticalText?: string
386
+ when?: number
387
+ chronometer?: boolean
388
+ chronometerCountDown?: boolean
389
+ showWhen?: boolean
390
+ largeIcon?: ImageSource
391
+ publicVersion?: AndroidOngoingNotificationPublicVersion
392
+ } => {
393
+ const when = normalizeWhen(props.when)
394
+
395
+ return {
396
+ title: assertOptionalNonEmptyString(props.title, 'title'),
397
+ subText: assertOptionalString(props.subText, 'subText'),
398
+ shortCriticalText: assertOptionalString(props.shortCriticalText, 'shortCriticalText'),
399
+ when,
400
+ ...normalizeChronometer(props.chronometer, when),
401
+ showWhen: assertBoolean(props.showWhen, 'showWhen'),
402
+ largeIcon: assertOptionalImageSource(props.largeIcon, 'largeIcon'),
403
+ publicVersion: normalizePublicVersion(props.publicVersion),
404
+ }
405
+ }
406
+
253
407
  const normalizeActions = (value: ReactNode): AndroidOngoingNotificationActionPayload[] | undefined => {
254
408
  const actions: AndroidOngoingNotificationActionPayload[] = []
255
409
 
@@ -293,16 +447,11 @@ const normalizeProgressPayload = (
293
447
  return {
294
448
  v: PAYLOAD_VERSION,
295
449
  kind: 'progress',
296
- title: assertOptionalNonEmptyString(props.title, 'title'),
297
- subText: assertOptionalString(props.subText, 'subText'),
450
+ ...normalizeCommonDisplayFields(props),
298
451
  text: assertOptionalString(props.text, 'text'),
299
452
  value,
300
453
  max,
301
454
  indeterminate: assertBoolean(props.indeterminate, 'indeterminate'),
302
- shortCriticalText: assertOptionalString(props.shortCriticalText, 'shortCriticalText'),
303
- when: normalizeWhen(props.when),
304
- chronometer: assertBoolean(props.chronometer, 'chronometer'),
305
- largeIcon: assertOptionalImageSource(props.largeIcon, 'largeIcon'),
306
455
  progressTrackerIcon: assertOptionalImageSource(props.progressTrackerIcon, 'progressTrackerIcon'),
307
456
  progressStartIcon: assertOptionalImageSource(props.progressStartIcon, 'progressStartIcon'),
308
457
  progressEndIcon: assertOptionalImageSource(props.progressEndIcon, 'progressEndIcon'),
@@ -320,14 +469,286 @@ const normalizeBigTextPayload = (
320
469
  return {
321
470
  v: PAYLOAD_VERSION,
322
471
  kind: 'bigText',
323
- title: assertOptionalNonEmptyString(props.title, 'title'),
324
- subText: assertOptionalString(props.subText, 'subText'),
472
+ ...normalizeCommonDisplayFields(props),
325
473
  text,
326
474
  bigText: assertOptionalString(props.bigText, 'bigText') ?? text,
327
- shortCriticalText: assertOptionalString(props.shortCriticalText, 'shortCriticalText'),
328
- when: normalizeWhen(props.when),
329
- chronometer: assertBoolean(props.chronometer, 'chronometer'),
330
- largeIcon: assertOptionalImageSource(props.largeIcon, 'largeIcon'),
475
+ actions: normalizeActions(props.children),
476
+ }
477
+ }
478
+
479
+ const METRIC_TIME_PATTERN = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/
480
+
481
+ const METRIC_SEMANTIC_STYLES = ['unspecified', 'info', 'safe', 'caution', 'danger'] as const
482
+
483
+ const METRIC_TIME_FORMATS = ['adaptive', 'chronometer'] as const
484
+
485
+ const assertMetricNonEmptyString = (value: unknown, propName: string): string => {
486
+ if (typeof value !== 'string' || value.length === 0) {
487
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be a non-empty string.`)
488
+ }
489
+
490
+ return value
491
+ }
492
+
493
+ const normalizeMetricTimeFormat = (value: unknown, propName: string) => {
494
+ if (value === undefined) {
495
+ return undefined
496
+ }
497
+
498
+ if (!(METRIC_TIME_FORMATS as readonly unknown[]).includes(value)) {
499
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}" must be "adaptive" or "chronometer".`)
500
+ }
501
+
502
+ return value as (typeof METRIC_TIME_FORMATS)[number]
503
+ }
504
+
505
+ const normalizeMetricValue = (
506
+ value: unknown,
507
+ unit: unknown,
508
+ propName: string
509
+ ): AndroidOngoingNotificationMetricValuePayload => {
510
+ const topLevelUnit = assertOptionalString(unit, `${propName}.unit`)
511
+ const rejectTopLevelUnit = () => {
512
+ if (topLevelUnit !== undefined) {
513
+ throw new Error(
514
+ `[Voltra] [Android] Ongoing notification prop "${propName}.unit" only applies to "int", "float" and "text" values.`
515
+ )
516
+ }
517
+ }
518
+
519
+ // Shorthands: an integer reads as int, any other number as float, a string as text.
520
+ if (typeof value === 'number') {
521
+ if (!Number.isFinite(value)) {
522
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}.value" must be a finite number.`)
523
+ }
524
+
525
+ return Number.isInteger(value)
526
+ ? { type: 'int', value, unit: topLevelUnit }
527
+ : { type: 'float', value, unit: topLevelUnit }
528
+ }
529
+
530
+ if (typeof value === 'string') {
531
+ return { type: 'text', value: assertMetricNonEmptyString(value, `${propName}.value`), unit: topLevelUnit }
532
+ }
533
+
534
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
535
+ throw new Error(
536
+ `[Voltra] [Android] Ongoing notification prop "${propName}.value" must be a number, a string, or a metric value object.`
537
+ )
538
+ }
539
+
540
+ const source = value as Record<string, unknown>
541
+ const type = source.type
542
+
543
+ switch (type) {
544
+ case 'int': {
545
+ const numberValue = assertFiniteNumber(source.value, `${propName}.value`)
546
+ if (!Number.isInteger(numberValue)) {
547
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "${propName}.value" must be an integer.`)
548
+ }
549
+
550
+ return {
551
+ type: 'int',
552
+ value: numberValue,
553
+ unit: assertOptionalString(source.unit, `${propName}.unit`) ?? topLevelUnit,
554
+ }
555
+ }
556
+ case 'float': {
557
+ const numberValue = assertFiniteNumber(source.value, `${propName}.value`)
558
+ const min = source.min === undefined ? undefined : assertFiniteNumber(source.min, `${propName}.min`)
559
+ const max = source.max === undefined ? undefined : assertFiniteNumber(source.max, `${propName}.max`)
560
+
561
+ if (min !== undefined && min < 0) {
562
+ throw new Error(
563
+ `[Voltra] [Android] Ongoing notification prop "${propName}.min" must be greater than or equal to 0.`
564
+ )
565
+ }
566
+
567
+ if (max !== undefined && max < 0) {
568
+ throw new Error(
569
+ `[Voltra] [Android] Ongoing notification prop "${propName}.max" must be greater than or equal to 0.`
570
+ )
571
+ }
572
+
573
+ if (min !== undefined && max !== undefined && min > max) {
574
+ throw new Error(
575
+ `[Voltra] [Android] Ongoing notification prop "${propName}.min" must not exceed "${propName}.max".`
576
+ )
577
+ }
578
+
579
+ let fractionDigits: number | undefined
580
+ if (source.fractionDigits !== undefined) {
581
+ fractionDigits = assertFiniteNumber(source.fractionDigits, `${propName}.fractionDigits`)
582
+ if (!Number.isInteger(fractionDigits) || fractionDigits < 0) {
583
+ throw new Error(
584
+ `[Voltra] [Android] Ongoing notification prop "${propName}.fractionDigits" must be an integer greater than or equal to 0.`
585
+ )
586
+ }
587
+ }
588
+
589
+ return {
590
+ type: 'float',
591
+ value: numberValue,
592
+ unit: assertOptionalString(source.unit, `${propName}.unit`) ?? topLevelUnit,
593
+ min,
594
+ max,
595
+ fractionDigits,
596
+ }
597
+ }
598
+ case 'text':
599
+ return {
600
+ type: 'text',
601
+ value: assertMetricNonEmptyString(source.value, `${propName}.value`),
602
+ unit: assertOptionalString(source.unit, `${propName}.unit`) ?? topLevelUnit,
603
+ }
604
+ case 'time': {
605
+ rejectTopLevelUnit()
606
+ const timeValue = assertMetricNonEmptyString(source.value, `${propName}.value`)
607
+ if (!METRIC_TIME_PATTERN.test(timeValue)) {
608
+ throw new Error(
609
+ `[Voltra] [Android] Ongoing notification prop "${propName}.value" must be a time in "HH:mm" or "HH:mm:ss" form.`
610
+ )
611
+ }
612
+
613
+ return { type: 'time', value: timeValue }
614
+ }
615
+ case 'timer':
616
+ rejectTopLevelUnit()
617
+ return {
618
+ type: 'timer',
619
+ endsAt: normalizeEpoch(source.endsAt, `${propName}.endsAt`),
620
+ format: normalizeMetricTimeFormat(source.format, `${propName}.format`),
621
+ }
622
+ case 'stopwatch':
623
+ rejectTopLevelUnit()
624
+ return {
625
+ type: 'stopwatch',
626
+ startedAt: normalizeEpoch(source.startedAt, `${propName}.startedAt`),
627
+ format: normalizeMetricTimeFormat(source.format, `${propName}.format`),
628
+ }
629
+ case 'pausedTimer': {
630
+ rejectTopLevelUnit()
631
+ const remainingMillis = assertFiniteNumber(source.remainingMillis, `${propName}.remainingMillis`)
632
+ if (remainingMillis < 0) {
633
+ throw new Error(
634
+ `[Voltra] [Android] Ongoing notification prop "${propName}.remainingMillis" must be greater than or equal to 0.`
635
+ )
636
+ }
637
+
638
+ return { type: 'pausedTimer', remainingMillis }
639
+ }
640
+ case 'pausedStopwatch': {
641
+ rejectTopLevelUnit()
642
+ const elapsedMillis = assertFiniteNumber(source.elapsedMillis, `${propName}.elapsedMillis`)
643
+ if (elapsedMillis < 0) {
644
+ throw new Error(
645
+ `[Voltra] [Android] Ongoing notification prop "${propName}.elapsedMillis" must be greater than or equal to 0.`
646
+ )
647
+ }
648
+
649
+ return { type: 'pausedStopwatch', elapsedMillis }
650
+ }
651
+ default:
652
+ throw new Error(
653
+ `[Voltra] [Android] Ongoing notification prop "${propName}.type" must be "int", "float", "text", "time", "timer", "stopwatch", "pausedTimer", or "pausedStopwatch".`
654
+ )
655
+ }
656
+ }
657
+
658
+ const normalizeMetricPayload = (
659
+ props: AndroidOngoingNotificationMetricProps
660
+ ): AndroidOngoingNotificationMetricPayload => {
661
+ if (!Array.isArray(props.metrics)) {
662
+ throw new Error('[Voltra] [Android] Ongoing notification prop "metrics" must be an array.')
663
+ }
664
+
665
+ if (props.metrics.length < 1 || props.metrics.length > 3) {
666
+ throw new Error('[Voltra] [Android] Ongoing notification prop "metrics" must contain between 1 and 3 metrics.')
667
+ }
668
+
669
+ const metrics = props.metrics.map((descriptor, index) => {
670
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
671
+ throw new Error(`[Voltra] [Android] Ongoing notification prop "metrics[${index}]" must be an object.`)
672
+ }
673
+
674
+ const label = assertMetricNonEmptyString(descriptor.label, `metrics[${index}].label`)
675
+ if (label.length > 10) {
676
+ throw new Error(
677
+ `[Voltra] [Android] Ongoing notification prop "metrics[${index}].label" must be at most 10 characters long.`
678
+ )
679
+ }
680
+
681
+ return { label, value: normalizeMetricValue(descriptor.value, descriptor.unit, `metrics[${index}]`) }
682
+ })
683
+
684
+ let criticalMetric: number | undefined
685
+ if (props.criticalMetric !== undefined) {
686
+ criticalMetric = assertFiniteNumber(props.criticalMetric, 'criticalMetric')
687
+ if (!Number.isInteger(criticalMetric) || criticalMetric < 0 || criticalMetric >= metrics.length) {
688
+ throw new Error(
689
+ `[Voltra] [Android] Ongoing notification prop "criticalMetric" must be an index between 0 and ${
690
+ metrics.length - 1
691
+ }.`
692
+ )
693
+ }
694
+ }
695
+
696
+ let semanticStyle: AndroidOngoingNotificationMetricSemanticStyle | undefined
697
+ if (props.semanticStyle !== undefined) {
698
+ if (!(METRIC_SEMANTIC_STYLES as readonly string[]).includes(props.semanticStyle)) {
699
+ throw new Error(
700
+ '[Voltra] [Android] Ongoing notification prop "semanticStyle" must be "unspecified", "info", "safe", "caution", or "danger".'
701
+ )
702
+ }
703
+
704
+ semanticStyle = props.semanticStyle
705
+ }
706
+
707
+ return {
708
+ v: PAYLOAD_VERSION,
709
+ kind: 'metric',
710
+ ...normalizeCommonDisplayFields(props),
711
+ metrics,
712
+ criticalMetric,
713
+ semanticStyle,
714
+ actions: normalizeActions(props.children),
715
+ }
716
+ }
717
+
718
+ const normalizeBigPicturePayload = (
719
+ props: AndroidOngoingNotificationBigPictureProps
720
+ ): AndroidOngoingNotificationBigPicturePayload => {
721
+ const picture = assertRequiredImageSource(props.picture, 'picture')
722
+ warnInlinePicture(picture)
723
+
724
+ return {
725
+ v: PAYLOAD_VERSION,
726
+ kind: 'bigPicture',
727
+ ...normalizeCommonDisplayFields(props),
728
+ text: assertOptionalString(props.text, 'text'),
729
+ picture,
730
+ summaryText: assertOptionalNonEmptyString(props.summaryText, 'summaryText'),
731
+ pictureContentDescription: assertOptionalNonEmptyString(
732
+ props.pictureContentDescription,
733
+ 'pictureContentDescription'
734
+ ),
735
+ showPictureWhenCollapsed: assertBoolean(props.showPictureWhenCollapsed, 'showPictureWhenCollapsed'),
736
+ bigLargeIcon: assertOptionalImageSource(props.bigLargeIcon, 'bigLargeIcon'),
737
+ hideLargeIconWhenExpanded: assertBoolean(props.hideLargeIconWhenExpanded, 'hideLargeIconWhenExpanded'),
738
+ actions: normalizeActions(props.children),
739
+ }
740
+ }
741
+
742
+ const normalizeInboxPayload = (props: AndroidOngoingNotificationInboxProps): AndroidOngoingNotificationInboxPayload => {
743
+ const lines = normalizeInboxLines(props.lines)
744
+
745
+ return {
746
+ v: PAYLOAD_VERSION,
747
+ kind: 'inbox',
748
+ ...normalizeCommonDisplayFields(props),
749
+ text: props.text === undefined ? lines[0] : assertString(props.text, 'text'),
750
+ lines,
751
+ summaryText: assertOptionalNonEmptyString(props.summaryText, 'summaryText'),
331
752
  actions: normalizeActions(props.children),
332
753
  }
333
754
  }
@@ -346,8 +767,20 @@ export const renderAndroidOngoingNotificationPayloadToJson = (
346
767
  return normalizeBigTextPayload(element.props as AndroidOngoingNotificationBigTextProps)
347
768
  }
348
769
 
770
+ if (kind === 'bigPicture') {
771
+ return normalizeBigPicturePayload(element.props as AndroidOngoingNotificationBigPictureProps)
772
+ }
773
+
774
+ if (kind === 'inbox') {
775
+ return normalizeInboxPayload(element.props as AndroidOngoingNotificationInboxProps)
776
+ }
777
+
778
+ if (kind === 'metric') {
779
+ return normalizeMetricPayload(element.props as AndroidOngoingNotificationMetricProps)
780
+ }
781
+
349
782
  throw new Error(
350
- '[Voltra] [Android] Ongoing notification content must use AndroidOngoingNotification.Progress or AndroidOngoingNotification.BigText.'
783
+ '[Voltra] [Android] Ongoing notification content must use AndroidOngoingNotification.Progress, AndroidOngoingNotification.BigText, AndroidOngoingNotification.BigPicture, AndroidOngoingNotification.Inbox, or AndroidOngoingNotification.Metric.'
351
784
  )
352
785
  }
353
786