@iternio/react-native-auto-play 0.3.11 → 0.3.12

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 (118) hide show
  1. package/README.md +60 -2
  2. package/android/src/automotive/AndroidManifest.xml +1 -0
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt +91 -0
  5. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt +214 -0
  6. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/MapTemplate.kt +2 -1
  7. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/Parser.kt +108 -38
  8. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/utils/BitmapCache.kt +14 -0
  9. package/ios/extensions/NitroImageExtensions.swift +10 -1
  10. package/ios/hybrid/HybridAutoPlay.swift +51 -4
  11. package/ios/templates/GridTemplate.swift +7 -0
  12. package/ios/templates/MapTemplate.swift +14 -0
  13. package/ios/templates/Parser.swift +91 -4
  14. package/ios/utils/VoiceInputManager.swift +233 -0
  15. package/lib/specs/AutoPlay.nitro.d.ts +31 -1
  16. package/lib/types/Image.d.ts +13 -0
  17. package/lib/utils/NitroImage.d.ts +6 -1
  18. package/lib/utils/NitroImage.js +7 -0
  19. package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +1 -1
  20. package/nitrogen/generated/android/c++/JGridTemplateConfig.hpp +3 -1
  21. package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.cpp +48 -1
  22. package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.hpp +4 -0
  23. package/nitrogen/generated/android/c++/JHybridClusterSpec.cpp +4 -0
  24. package/nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp +5 -1
  25. package/nitrogen/generated/android/c++/JHybridInformationTemplateSpec.cpp +5 -1
  26. package/nitrogen/generated/android/c++/JHybridListTemplateSpec.cpp +5 -1
  27. package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.cpp +5 -1
  28. package/nitrogen/generated/android/c++/JHybridMessageTemplateSpec.cpp +5 -1
  29. package/nitrogen/generated/android/c++/JHybridSearchTemplateSpec.cpp +5 -1
  30. package/nitrogen/generated/android/c++/JHybridSignInTemplateSpec.cpp +5 -1
  31. package/nitrogen/generated/android/c++/JImageLane.hpp +2 -0
  32. package/nitrogen/generated/android/c++/JInformationTemplateConfig.hpp +3 -1
  33. package/nitrogen/generated/android/c++/JLaneGuidance.hpp +2 -0
  34. package/nitrogen/generated/android/c++/JListTemplateConfig.hpp +3 -1
  35. package/nitrogen/generated/android/c++/JMapTemplateConfig.hpp +3 -1
  36. package/nitrogen/generated/android/c++/JMessageTemplateConfig.hpp +7 -5
  37. package/nitrogen/generated/android/c++/JNitroAction.hpp +7 -5
  38. package/nitrogen/generated/android/c++/JNitroAttributedString.hpp +2 -0
  39. package/nitrogen/generated/android/c++/JNitroAttributedStringImage.hpp +2 -0
  40. package/nitrogen/generated/android/c++/JNitroBaseMapTemplateConfig.hpp +3 -1
  41. package/nitrogen/generated/android/c++/JNitroGridButton.hpp +2 -0
  42. package/nitrogen/generated/android/c++/JNitroImage.cpp +6 -2
  43. package/nitrogen/generated/android/c++/JNitroImage.hpp +19 -2
  44. package/nitrogen/generated/android/c++/JNitroManeuver.hpp +3 -1
  45. package/nitrogen/generated/android/c++/JNitroMapButton.hpp +2 -0
  46. package/nitrogen/generated/android/c++/JNitroMessageManeuver.hpp +7 -5
  47. package/nitrogen/generated/android/c++/JNitroNavigationAlert.hpp +7 -5
  48. package/nitrogen/generated/android/c++/JNitroRoutingManeuver.hpp +7 -5
  49. package/nitrogen/generated/android/c++/JNitroRow.hpp +7 -5
  50. package/nitrogen/generated/android/c++/JNitroSection.hpp +3 -1
  51. package/nitrogen/generated/android/c++/JPreferredImageLane.hpp +2 -0
  52. package/nitrogen/generated/android/c++/JRemoteImage.hpp +68 -0
  53. package/nitrogen/generated/android/c++/JSearchTemplateConfig.hpp +3 -1
  54. package/nitrogen/generated/android/c++/JSignInTemplateConfig.hpp +3 -1
  55. package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.cpp +30 -0
  56. package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.hpp +92 -0
  57. package/nitrogen/generated/android/c++/JVariant_PreferredImageLane_ImageLane.hpp +2 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlaySpec.kt +17 -0
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/MessageTemplateConfig.kt +3 -3
  60. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroAction.kt +3 -3
  61. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroImage.kt +14 -2
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroMessageManeuver.kt +2 -2
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroNavigationAlert.kt +3 -3
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRoutingManeuver.kt +2 -2
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRow.kt +3 -3
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/RemoteImage.kt +44 -0
  67. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/{Variant_GlyphImage_AssetImage.kt → Variant_GlyphImage_AssetImage_RemoteImage.kt} +20 -8
  68. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.cpp +16 -8
  69. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.hpp +156 -79
  70. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Umbrella.hpp +4 -0
  71. package/nitrogen/generated/ios/c++/HybridAutoPlaySpecSwift.hpp +37 -0
  72. package/nitrogen/generated/ios/c++/HybridCarPlayDashboardSpecSwift.hpp +3 -0
  73. package/nitrogen/generated/ios/c++/HybridClusterSpecSwift.hpp +3 -0
  74. package/nitrogen/generated/ios/c++/HybridGridTemplateSpecSwift.hpp +3 -0
  75. package/nitrogen/generated/ios/c++/HybridInformationTemplateSpecSwift.hpp +3 -0
  76. package/nitrogen/generated/ios/c++/HybridListTemplateSpecSwift.hpp +3 -0
  77. package/nitrogen/generated/ios/c++/HybridMapTemplateSpecSwift.hpp +3 -0
  78. package/nitrogen/generated/ios/c++/HybridMessageTemplateSpecSwift.hpp +3 -0
  79. package/nitrogen/generated/ios/c++/HybridSearchTemplateSpecSwift.hpp +3 -0
  80. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_ArrayBuffer_.swift +46 -0
  81. package/nitrogen/generated/ios/swift/HybridAutoPlaySpec.swift +4 -0
  82. package/nitrogen/generated/ios/swift/HybridAutoPlaySpec_cxx.swift +82 -0
  83. package/nitrogen/generated/ios/swift/ImageLane.swift +9 -4
  84. package/nitrogen/generated/ios/swift/MessageTemplateConfig.swift +16 -11
  85. package/nitrogen/generated/ios/swift/NitroAction.swift +16 -11
  86. package/nitrogen/generated/ios/swift/NitroAttributedStringImage.swift +9 -4
  87. package/nitrogen/generated/ios/swift/NitroCarPlayDashboardButton.swift +9 -4
  88. package/nitrogen/generated/ios/swift/NitroGridButton.swift +9 -4
  89. package/nitrogen/generated/ios/swift/NitroImage.swift +2 -1
  90. package/nitrogen/generated/ios/swift/NitroMapButton.swift +9 -4
  91. package/nitrogen/generated/ios/swift/NitroMessageManeuver.swift +16 -11
  92. package/nitrogen/generated/ios/swift/NitroNavigationAlert.swift +16 -11
  93. package/nitrogen/generated/ios/swift/NitroRoutingManeuver.swift +25 -15
  94. package/nitrogen/generated/ios/swift/NitroRow.swift +16 -11
  95. package/nitrogen/generated/ios/swift/PreferredImageLane.swift +9 -4
  96. package/nitrogen/generated/ios/swift/RemoteImage.swift +58 -0
  97. package/nitrogen/generated/ios/swift/{Variant_GlyphImage_AssetImage.swift → Variant_GlyphImage_AssetImage_RemoteImage.swift} +4 -3
  98. package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.cpp +4 -0
  99. package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.hpp +5 -0
  100. package/nitrogen/generated/shared/c++/ImageLane.hpp +8 -5
  101. package/nitrogen/generated/shared/c++/MessageTemplateConfig.hpp +8 -5
  102. package/nitrogen/generated/shared/c++/NitroAction.hpp +8 -5
  103. package/nitrogen/generated/shared/c++/NitroAttributedStringImage.hpp +8 -5
  104. package/nitrogen/generated/shared/c++/NitroCarPlayDashboardButton.hpp +8 -5
  105. package/nitrogen/generated/shared/c++/NitroGridButton.hpp +8 -5
  106. package/nitrogen/generated/shared/c++/NitroMapButton.hpp +8 -5
  107. package/nitrogen/generated/shared/c++/NitroMessageManeuver.hpp +8 -5
  108. package/nitrogen/generated/shared/c++/NitroNavigationAlert.hpp +8 -5
  109. package/nitrogen/generated/shared/c++/NitroRoutingManeuver.hpp +12 -9
  110. package/nitrogen/generated/shared/c++/NitroRow.hpp +8 -5
  111. package/nitrogen/generated/shared/c++/PreferredImageLane.hpp +8 -5
  112. package/nitrogen/generated/shared/c++/RemoteImage.hpp +94 -0
  113. package/package.json +1 -1
  114. package/src/specs/AutoPlay.nitro.ts +39 -1
  115. package/src/types/Image.ts +14 -0
  116. package/src/utils/NitroImage.ts +15 -1
  117. package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage.cpp +0 -26
  118. package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage.hpp +0 -75
package/README.md CHANGED
@@ -613,7 +613,7 @@ This section lists the available listeners and lifecycle callbacks so you can wi
613
613
  | --- | --- | --- |
614
614
  | `HybridAutoPlay.addListener(event, cb)` | event: `'didConnect'` `'didDisconnect'` | Connection changes for the head unit. |
615
615
  | `HybridAutoPlay.addListenerRenderState(moduleName, cb)` | `cb(visibility: 'willAppear' \| 'didAppear' \| 'willDisappear' \| 'didDisappear')` | Use `AutoPlayModules.*` or a cluster UUID. |
616
- | `HybridAutoPlay.addListenerVoiceInput(cb)` | `cb(location?, query?)` | Android-only voice input. |
616
+ | `HybridAutoPlay.addListenerVoiceInput(cb)` | `cb(location?, query?)` | Android-only. Fires when the OS triggers a voice input event (e.g. "Hey Google, navigate to…"). For in-app recording use `startVoiceInput` instead. |
617
617
  | `HybridAutoPlay.addSafeAreaInsetsListener(moduleName, cb)` | `cb(insets)` | Safe area inset changes for any module. |
618
618
 
619
619
  ```ts
@@ -760,10 +760,68 @@ new ListTemplate({
760
760
  }).push();
761
761
  ```
762
762
 
763
+ ### Voice Input
764
+
765
+ The library provides a cross-platform in-app voice recording API built on top of the car microphone (when connected) or the device microphone (when no car is connected).
766
+
767
+ #### Permission
768
+
769
+ ```ts
770
+ // Check whether permission is already granted
771
+ const granted = HybridAutoPlay.hasVoiceInputPermission();
772
+
773
+ // Request permission if not yet granted
774
+ const granted = await HybridAutoPlay.requestVoiceInputPermission();
775
+ ```
776
+
777
+ #### Recording
778
+
779
+ ```ts
780
+ // Start recording — resolves with a raw PCM ArrayBuffer (16 kHz, 16-bit, mono)
781
+ // Recording stops automatically on silence or when maxDurationMs is reached
782
+ const pcmBuffer = await HybridAutoPlay.startVoiceInput(
783
+ 1500, // silenceThresholdMs (default 1500)
784
+ 10_000, // maxDurationMs (default 10 000)
785
+ 'Listening...' // text shown on the car screen while recording (iOS CarPlay)
786
+ );
787
+
788
+ // Stop recording early — resolves startVoiceInput with the audio captured so far
789
+ HybridAutoPlay.stopVoiceInput();
790
+ ```
791
+
792
+ On **Android**: uses `CarAudioRecord` when Android Auto is connected, otherwise falls back to standard `AudioRecord`.
793
+
794
+ On **iOS**: presents `CPVoiceControlTemplate` on the car screen when CarPlay is connected, and captures audio via `AVAudioEngine`.
795
+
796
+ #### OS-triggered voice input (Android only)
797
+
798
+ `addListenerVoiceInput` fires when the OS itself initiates a voice action (e.g. "Hey Google, navigate to…"). It is a no-op on iOS — use `startVoiceInput` for in-app recording on both platforms.
799
+
800
+ ```ts
801
+ const cleanup = HybridAutoPlay.addListenerVoiceInput((location, query) => {
802
+ console.log('Voice query:', query, 'near', location);
803
+ });
804
+ ```
805
+
806
+ #### useVoiceInput hook (Android only)
807
+
808
+ A convenience hook that wires up `addListenerVoiceInput` and exposes the latest `location` and `query` values reactively.
809
+
810
+ ```tsx
811
+ import { useVoiceInput } from '@iternio/react-native-auto-play';
812
+
813
+ const MyScreen = () => {
814
+ const { location, query } = useVoiceInput();
815
+ return <Text>{query ?? 'Say something…'}</Text>;
816
+ };
817
+ ```
818
+
819
+ ---
820
+
763
821
  ### Hooks
764
822
 
765
823
  - `useMapTemplate()`: Get a reference to the parent `MapTemplate` instance.
766
- - `useVoiceInput()`: Access voice input functionality - Android Auto only.
824
+ - `useVoiceInput()`: Reactively exposes the latest OS-triggered voice input (`location`, `query`). Android only — for in-app recording use `startVoiceInput` / `stopVoiceInput` directly.
767
825
  - `useSafeAreaInsets()`: Get safe area insets for any root component.
768
826
  - `useFocusedEffect()`: A useEffect alternative that executes when the specified component is visible to the user - use any of the `AutoPlayModules` enum or a cluster uuid to sepcify the component the effect should listen for.
769
827
  - `useAndroidAutoTelemetry()`: Access to car telemetry data on Android Auto and Android Automotive.
@@ -19,6 +19,7 @@
19
19
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
20
20
 
21
21
  <uses-permission android:name="android.permission.WAKE_LOCK" />
22
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
22
23
 
23
24
 
24
25
  <!-- Android Automotive specific permissions -->
@@ -12,6 +12,7 @@
12
12
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
13
13
 
14
14
  <uses-permission android:name="android.permission.WAKE_LOCK" />
15
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
15
16
 
16
17
  <!-- Android Auto specific permissions -->
17
18
  <uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
@@ -1,12 +1,22 @@
1
1
  package com.margelo.nitro.swe.iternio.reactnativeautoplay
2
2
 
3
+ import android.content.pm.PackageManager
4
+ import android.os.Build
5
+ import androidx.core.content.ContextCompat
3
6
  import com.facebook.react.bridge.UiThreadUtil
7
+ import com.facebook.react.modules.core.PermissionAwareActivity
8
+ import com.facebook.react.modules.core.PermissionListener
9
+ import com.margelo.nitro.NitroModules
10
+ import com.margelo.nitro.core.ArrayBuffer
4
11
  import com.margelo.nitro.core.Promise
5
12
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.AndroidAutoTemplate
6
13
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.MessageTemplate
7
14
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.ThreadUtil
15
+ import kotlinx.coroutines.suspendCancellableCoroutine
16
+ import java.nio.ByteBuffer
8
17
  import java.util.concurrent.ConcurrentHashMap
9
18
  import java.util.concurrent.CopyOnWriteArrayList
19
+ import kotlin.coroutines.resume
10
20
 
11
21
  class HybridAutoPlay : HybridAutoPlaySpec() {
12
22
  init {
@@ -241,6 +251,84 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
241
251
  }
242
252
  }
243
253
 
254
+ override fun hasVoiceInputPermission(): Boolean {
255
+ val context = NitroModules.applicationContext ?: return false
256
+ return ContextCompat.checkSelfPermission(
257
+ context, android.Manifest.permission.RECORD_AUDIO
258
+ ) == PackageManager.PERMISSION_GRANTED
259
+ }
260
+
261
+ override fun requestVoiceInputPermission(): Promise<Boolean> {
262
+ return Promise.async {
263
+ if (hasVoiceInputPermission()) {
264
+ return@async true
265
+ }
266
+
267
+ val carContext = AndroidAutoSession.getRootContext()
268
+
269
+ if (carContext != null) {
270
+ suspendCancellableCoroutine {
271
+ carContext.requestPermissions(listOf(android.Manifest.permission.RECORD_AUDIO)) { approved, _ ->
272
+ it.resume(approved.contains(android.Manifest.permission.RECORD_AUDIO))
273
+ }
274
+ }
275
+ } else {
276
+ val context = NitroModules.applicationContext ?: return@async false
277
+ val activity =
278
+ context.currentActivity as? PermissionAwareActivity ?: return@async false
279
+ val code = (Math.random() * 10000).toInt()
280
+
281
+ suspendCancellableCoroutine {
282
+ activity.requestPermissions(
283
+ arrayOf(android.Manifest.permission.RECORD_AUDIO),
284
+ code,
285
+ PermissionListener { requestCode, _, grantResults ->
286
+ if (requestCode != code) {
287
+ return@PermissionListener false
288
+ }
289
+
290
+ val granted =
291
+ grantResults.isNotEmpty() && grantResults.first() == PackageManager.PERMISSION_GRANTED
292
+
293
+ it.resume(granted)
294
+
295
+ return@PermissionListener true
296
+ })
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ override fun startVoiceInput(
303
+ silenceThresholdMs: Double?, maxDurationMs: Double?, listeningText: String?
304
+ ): Promise<ArrayBuffer> {
305
+ return Promise.async {
306
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
307
+ throw UnsupportedOperationException("startVoiceInput requires at least API level ${Build.VERSION_CODES.O}")
308
+ }
309
+
310
+ val manager = VoiceInputManager(AndroidAutoSession.getRootContext())
311
+ voiceInputManager = manager
312
+
313
+ try {
314
+ val pcmBytes = manager.start(
315
+ silenceThresholdMs = silenceThresholdMs?.toLong() ?: 1_500L,
316
+ maxDurationMs = maxDurationMs?.toLong() ?: 10_000L,
317
+ )
318
+ val directBuffer =
319
+ ByteBuffer.allocateDirect(pcmBytes.size).put(pcmBytes).rewind() as ByteBuffer
320
+ ArrayBuffer.wrap(directBuffer)
321
+ } finally {
322
+ voiceInputManager = null
323
+ manager.dispose()
324
+ }
325
+ }
326
+ }
327
+
328
+ override fun stopVoiceInput() {
329
+ voiceInputManager?.stop()
330
+ }
331
+
244
332
  companion object {
245
333
  const val TAG = "HybridAutoPlay"
246
334
 
@@ -251,6 +339,9 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
251
339
 
252
340
  private val voiceInputListeners = CopyOnWriteArrayList<(Location?, String?) -> Unit>()
253
341
 
342
+ @Volatile
343
+ private var voiceInputManager: VoiceInputManager? = null
344
+
254
345
  private val safeAreaInsetsListeners =
255
346
  ConcurrentHashMap<String, CopyOnWriteArrayList<(SafeAreaInsets) -> Unit>>()
256
347
 
@@ -0,0 +1,214 @@
1
+ package com.margelo.nitro.swe.iternio.reactnativeautoplay
2
+
3
+ import android.content.pm.PackageManager
4
+ import android.media.AudioAttributes
5
+ import android.media.AudioFocusRequest
6
+ import android.media.AudioFormat
7
+ import android.media.AudioManager
8
+ import android.media.AudioRecord
9
+ import android.media.MediaRecorder
10
+ import android.os.Build
11
+ import androidx.annotation.RequiresApi
12
+ import androidx.car.app.CarContext
13
+ import androidx.car.app.media.CarAudioRecord
14
+ import androidx.core.content.ContextCompat
15
+ import com.margelo.nitro.NitroModules
16
+ import kotlinx.coroutines.CoroutineScope
17
+ import kotlinx.coroutines.Dispatchers
18
+ import kotlinx.coroutines.Job
19
+ import kotlinx.coroutines.cancel
20
+ import kotlinx.coroutines.launch
21
+ import kotlinx.coroutines.suspendCancellableCoroutine
22
+ import java.io.ByteArrayOutputStream
23
+ import kotlin.coroutines.Continuation
24
+ import kotlin.coroutines.resume
25
+ import kotlin.coroutines.resumeWithException
26
+ import kotlin.math.abs
27
+
28
+ /**
29
+ * Captures 16-bit PCM audio (16 kHz, mono).
30
+ * When [carContext] is provided uses CarAudioRecord (Android Auto/Automotive).
31
+ * When [carContext] is null falls back to standard AudioRecord (phone-only).
32
+ */
33
+ class VoiceInputManager(
34
+ private val carContext: CarContext?,
35
+ ) {
36
+ private var carAudioRecord: CarAudioRecord? = null
37
+ private var audioRecord: AudioRecord? = null
38
+ private var audioFocusRequest: AudioFocusRequest? = null
39
+ private var recordingJob: Job? = null
40
+ private var continuation: Continuation<ByteArray>? = null
41
+ private val scope = CoroutineScope(Dispatchers.IO)
42
+
43
+ @Volatile
44
+ private var isRecording = false
45
+
46
+ /**
47
+ * Acquires audio focus, starts recording, and suspends until stopped.
48
+ * Stops automatically after [silenceThresholdMs] of silence or [maxDurationMs] total.
49
+ * Returns the complete raw PCM buffer (Int16 LE, 16 kHz, mono).
50
+ */
51
+ @RequiresApi(Build.VERSION_CODES.O)
52
+ suspend fun start(
53
+ silenceThresholdMs: Long = 1_500,
54
+ maxDurationMs: Long = 10_000,
55
+ ): ByteArray = suspendCancellableCoroutine { cont ->
56
+ val appContext = NitroModules.applicationContext
57
+ if (appContext == null || ContextCompat.checkSelfPermission(
58
+ appContext,
59
+ android.Manifest.permission.RECORD_AUDIO,
60
+ ) != PackageManager.PERMISSION_GRANTED
61
+ ) {
62
+ cont.resumeWithException(SecurityException("RECORD_AUDIO permission not granted"))
63
+ return@suspendCancellableCoroutine
64
+ }
65
+
66
+ continuation = cont
67
+
68
+ val audioManager = appContext.getSystemService(AudioManager::class.java)
69
+
70
+ val audioAttributes =
71
+ AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
72
+ .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE).build()
73
+
74
+ val focusRequest =
75
+ AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
76
+ .setAudioAttributes(audioAttributes).setOnAudioFocusChangeListener { state ->
77
+ if (state == AudioManager.AUDIOFOCUS_LOSS) {
78
+ stop()
79
+ }
80
+ }.build()
81
+
82
+ if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
83
+ continuation = null
84
+ cont.resumeWithException(IllegalStateException("Audio focus request denied"))
85
+ return@suspendCancellableCoroutine
86
+ }
87
+
88
+ audioFocusRequest = focusRequest
89
+
90
+ val bufferSize: Int
91
+
92
+ if (carContext != null) {
93
+ val record = CarAudioRecord.create(carContext)
94
+ carAudioRecord = record
95
+ bufferSize = CarAudioRecord.AUDIO_CONTENT_BUFFER_SIZE
96
+ isRecording = true
97
+ record.startRecording()
98
+ } else {
99
+ val minBuffer = AudioRecord.getMinBufferSize(
100
+ SAMPLE_RATE,
101
+ AudioFormat.CHANNEL_IN_MONO,
102
+ AudioFormat.ENCODING_PCM_16BIT,
103
+ )
104
+ bufferSize = maxOf(minBuffer, PHONE_BUFFER_SIZE)
105
+ val record = AudioRecord(
106
+ MediaRecorder.AudioSource.MIC,
107
+ SAMPLE_RATE,
108
+ AudioFormat.CHANNEL_IN_MONO,
109
+ AudioFormat.ENCODING_PCM_16BIT,
110
+ bufferSize,
111
+ )
112
+ audioRecord = record
113
+ isRecording = true
114
+ record.startRecording()
115
+ }
116
+
117
+ val outputStream = ByteArrayOutputStream()
118
+
119
+ recordingJob = scope.launch {
120
+ val buffer = ByteArray(bufferSize)
121
+ val recordingStart = System.currentTimeMillis()
122
+ var silenceStart: Long? = null
123
+
124
+ try {
125
+ while (isRecording) {
126
+ val read = carAudioRecord?.read(buffer, 0, bufferSize) ?: audioRecord?.read(
127
+ buffer,
128
+ 0,
129
+ bufferSize,
130
+ ) ?: -1
131
+
132
+ if (read < 0) {
133
+ break
134
+ }
135
+
136
+ if (read > 0) {
137
+ outputStream.write(buffer, 0, read)
138
+
139
+ val now = System.currentTimeMillis()
140
+ val elapsedMs = now - recordingStart
141
+
142
+ if (elapsedMs >= maxDurationMs) {
143
+ break
144
+ }
145
+
146
+ // Silence detection — skip during warm-up
147
+ if (elapsedMs >= WARMUP_MS) {
148
+ var peak = 0
149
+ var i = 0
150
+ while (i < read - 1) {
151
+ val sample =
152
+ (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
153
+ val absSample = abs(sample.toShort().toInt())
154
+ if (absSample > peak) peak = absSample
155
+ i += 2
156
+ }
157
+
158
+ if (peak < SILENCE_AMPLITUDE_THRESHOLD) {
159
+ if (silenceStart == null) {
160
+ silenceStart = now
161
+ }
162
+ if (now - silenceStart >= silenceThresholdMs) {
163
+ break
164
+ }
165
+ } else {
166
+ silenceStart = null
167
+ }
168
+ }
169
+ }
170
+ }
171
+ } finally {
172
+ releaseResources()
173
+ val capturedContinuation = continuation
174
+ continuation = null
175
+ capturedContinuation?.resume(outputStream.toByteArray())
176
+ }
177
+ }
178
+ }
179
+
180
+ fun stop() {
181
+ isRecording = false
182
+ carAudioRecord?.stopRecording()
183
+ audioRecord?.stop()
184
+ }
185
+
186
+ @RequiresApi(Build.VERSION_CODES.O)
187
+ private fun releaseResources() {
188
+ carAudioRecord?.stopRecording()
189
+ carAudioRecord = null
190
+ audioRecord?.stop()
191
+ audioRecord?.release()
192
+ audioRecord = null
193
+ recordingJob = null
194
+ audioFocusRequest?.let {
195
+ val audioManager = (NitroModules.applicationContext ?: carContext)?.getSystemService(
196
+ AudioManager::class.java,
197
+ )
198
+ audioManager?.abandonAudioFocusRequest(it)
199
+ }
200
+ audioFocusRequest = null
201
+ }
202
+
203
+ fun dispose() {
204
+ stop()
205
+ scope.cancel()
206
+ }
207
+
208
+ companion object {
209
+ private const val SILENCE_AMPLITUDE_THRESHOLD = 500
210
+ private const val WARMUP_MS = 500L
211
+ private const val SAMPLE_RATE = 16_000
212
+ private const val PHONE_BUFFER_SIZE = 3_200 // ~100ms at 16kHz/16-bit/mono
213
+ }
214
+ }
@@ -377,7 +377,8 @@ class MapTemplate(
377
377
  val notificationIcon = Parser.parseImageToBitmap(
378
378
  context,
379
379
  current.symbolImage.asFirstOrNull(),
380
- current.symbolImage.asSecondOrNull()
380
+ current.symbolImage.asSecondOrNull(),
381
+ current.symbolImage.asThirdOrNull()
381
382
  )
382
383
 
383
384
  val notificationText = currentStep.cue?.toString()
@@ -40,6 +40,7 @@ import com.facebook.datasource.DataSources
40
40
  import com.facebook.drawee.backends.pipeline.Fresco
41
41
  import com.facebook.imagepipeline.image.CloseableBitmap
42
42
  import com.facebook.imagepipeline.image.CloseableXml
43
+ import com.facebook.imagepipeline.request.ImageRequest
43
44
  import com.facebook.imagepipeline.request.ImageRequestBuilder
44
45
  import com.facebook.react.views.imagehelper.ImageSource
45
46
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.AndroidAutoScreen
@@ -67,10 +68,11 @@ import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroRow
67
68
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroSectionType
68
69
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.OffRampType
69
70
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.OnRampType
71
+ import com.margelo.nitro.swe.iternio.reactnativeautoplay.RemoteImage
70
72
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.TrafficSide
71
73
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.TravelEstimates
72
74
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.TurnType
73
- import com.margelo.nitro.swe.iternio.reactnativeautoplay.Variant_GlyphImage_AssetImage
75
+ import com.margelo.nitro.swe.iternio.reactnativeautoplay.Variant_GlyphImage_AssetImage_RemoteImage
74
76
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.BitmapCache
75
77
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.SymbolFont
76
78
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.get
@@ -79,6 +81,7 @@ import java.util.Calendar
79
81
  import java.util.Locale
80
82
  import java.util.TimeZone
81
83
  import kotlin.math.abs
84
+ import androidx.core.net.toUri
82
85
 
83
86
  object Parser {
84
87
  const val TAG = "Parser"
@@ -210,27 +213,35 @@ object Parser {
210
213
  }.build()
211
214
  }
212
215
 
213
- fun parseImage(context: CarContext, image: Variant_GlyphImage_AssetImage): CarIcon {
214
- return parseImage(context, image.asFirstOrNull(), image.asSecondOrNull())
216
+ fun parseImage(context: CarContext, image: Variant_GlyphImage_AssetImage_RemoteImage): CarIcon {
217
+ return parseImage(context, image.asFirstOrNull(), image.asSecondOrNull(), image.asThirdOrNull())
215
218
  }
216
219
 
217
220
  fun parseImage(context: CarContext, image: NitroImage): CarIcon {
218
- return parseImage(context, image.asFirstOrNull(), image.asSecondOrNull())
221
+ return parseImage(context, image.asFirstOrNull(), image.asSecondOrNull(), image.asThirdOrNull())
219
222
  }
220
223
 
221
- fun parseImage(context: CarContext, glyphImage: GlyphImage?, assetImage: AssetImage?): CarIcon {
222
- val bitmap = parseImageToBitmap(context, glyphImage, assetImage)
224
+ fun parseImage(
225
+ context: CarContext,
226
+ glyphImage: GlyphImage?,
227
+ assetImage: AssetImage?,
228
+ remoteImage: RemoteImage?
229
+ ): CarIcon {
230
+ val bitmap = parseImageToBitmap(context, glyphImage, assetImage, remoteImage)
223
231
 
224
232
  bitmap?.let {
225
233
  return CarIcon.Builder(IconCompat.createWithBitmap(it)).build()
226
234
  }
227
235
 
228
- // this should not be possible, we just wanna satisfy kotlin
229
- return CarIcon.APP_ICON
236
+ // remote images might fail to load so we provide some placeholder then
237
+ return CarIcon.ALERT
230
238
  }
231
239
 
232
240
  fun parseImageToBitmap(
233
- context: CarContext, glyphImage: GlyphImage?, assetImage: AssetImage?
241
+ context: CarContext,
242
+ glyphImage: GlyphImage?,
243
+ assetImage: AssetImage?,
244
+ remoteImage: RemoteImage?
234
245
  ): Bitmap? {
235
246
  glyphImage?.let {
236
247
  return SymbolFont.imageFromNitroImage(
@@ -240,6 +251,9 @@ object Parser {
240
251
  assetImage?.let {
241
252
  return parseAssetImage(context, it)
242
253
  }
254
+ remoteImage?.let {
255
+ return parseRemoteImage(context, it)
256
+ }
243
257
 
244
258
  return null
245
259
  }
@@ -251,10 +265,14 @@ object Parser {
251
265
  fun imageFromNitroImages(
252
266
  context: CarContext, images: List<NitroImage>
253
267
  ): IconCompat {
254
- val bitmaps = images.map {
268
+ val bitmaps = images.mapNotNull {
255
269
  parseImageToBitmap(
256
- context, it.asFirstOrNull(), it.asSecondOrNull()
257
- )!!
270
+ context, it.asFirstOrNull(), it.asSecondOrNull(), it.asThirdOrNull()
271
+ )
272
+ }
273
+
274
+ if (bitmaps.isEmpty()) {
275
+ return IconCompat.createWithBitmap(createBitmap(1, 1))
258
276
  }
259
277
 
260
278
  val height = bitmaps.maxOf { it.height }
@@ -512,54 +530,106 @@ object Parser {
512
530
  }
513
531
 
514
532
  fun parseAssetImage(context: CarContext, assetImage: AssetImage): Bitmap? {
515
- var bitmap = BitmapCache.get(context, assetImage)
533
+ BitmapCache.get(context, assetImage)?.let { return it }
516
534
 
517
- if (bitmap != null) {
518
- return bitmap
519
- }
535
+ val imageRequest = buildImageRequest(ImageSource(context, assetImage.uri).uri)
536
+ val bitmap = fetchBitmap(context, imageRequest, timeoutMs = null) ?: return null
520
537
 
521
- val source = ImageSource(context, assetImage.uri)
522
- val imageRequest = ImageRequestBuilder.newBuilderWithSource(source.uri).disableDiskCache()
523
- .disableMemoryCache().build()
538
+ return applyTintAndCache(
539
+ context = context,
540
+ color = assetImage.color,
541
+ bitmap = bitmap,
542
+ cache = { result -> BitmapCache.put(context, assetImage, result) }
543
+ )
544
+ }
545
+
546
+ fun parseRemoteImage(context: CarContext, remoteImage: RemoteImage): Bitmap? {
547
+ BitmapCache.get(context, remoteImage)?.let { return it }
548
+
549
+ val imageRequest = buildImageRequest(remoteImage.uri.toUri())
550
+ val timeoutMs = remoteImage.timeoutMs?.toLong() ?: 500L
551
+ val bitmap = fetchBitmap(context, imageRequest, timeoutMs = timeoutMs) ?: return null
552
+
553
+ return applyTintAndCache(
554
+ context = context,
555
+ color = remoteImage.color,
556
+ bitmap = bitmap,
557
+ cache = { result -> BitmapCache.put(context, remoteImage, result) }
558
+ )
559
+ }
524
560
 
525
- val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context)
526
- val result = DataSources.waitForFinalResult(dataSource)
561
+ // Disable Fresco's own caching — BitmapCache handles all caching uniformly
562
+ private fun buildImageRequest(uri: android.net.Uri): ImageRequest =
563
+ ImageRequestBuilder.newBuilderWithSource(uri)
564
+ .disableDiskCache().disableMemoryCache().build()
565
+
566
+ /**
567
+ * Fetches a bitmap via Fresco's image pipeline.
568
+ * When [timeoutMs] is provided, the fetch runs on a background thread and is abandoned on timeout.
569
+ * When `null`, the fetch runs synchronously (bundled assets, no network I/O).
570
+ */
571
+ private fun fetchBitmap(
572
+ context: CarContext,
573
+ imageRequest: ImageRequest,
574
+ timeoutMs: Long?
575
+ ): Bitmap? {
576
+ if (timeoutMs == null) return fetchSync(context, imageRequest)
577
+
578
+ var fetched: Bitmap? = null
579
+ val thread = Thread { fetched = fetchSync(context, imageRequest) }
580
+ thread.start()
581
+ thread.join(timeoutMs)
582
+ if (thread.isAlive) {
583
+ // Fresco's waitForFinalResult is not interruptible, but setting the flag
584
+ // lets any subsequent interruptible call exit and signals intent to exit.
585
+ thread.interrupt()
586
+ return null
587
+ }
588
+ return fetched
589
+ }
527
590
 
591
+ private fun fetchSync(context: CarContext, imageRequest: ImageRequest): Bitmap? {
592
+ val dataSource = try {
593
+ Fresco.getImagePipeline().fetchDecodedImage(imageRequest, context)
594
+ } catch (_: Exception) { return null }
595
+ val result = try {
596
+ DataSources.waitForFinalResult(dataSource)
597
+ } catch (_: Exception) { dataSource.close(); return null }
528
598
  val image = result?.get()
529
599
  try {
530
600
  if (image is CloseableBitmap) {
531
- bitmap = image.underlyingBitmap.copy(Bitmap.Config.ARGB_8888, false)
601
+ return image.underlyingBitmap.copy(Bitmap.Config.ARGB_8888, false)
532
602
  } else if (image is CloseableXml) {
533
603
  val drawable = image.buildDrawable()
534
- bitmap = drawable?.toBitmap(
535
- width = image.width, height = image.height, Bitmap.Config.ARGB_8888
536
- )
604
+ return drawable?.toBitmap(width = image.width, height = image.height, Bitmap.Config.ARGB_8888)
537
605
  }
538
606
  } finally {
539
607
  image?.close()
540
608
  result?.close()
541
609
  dataSource.close()
542
610
  }
611
+ return null
612
+ }
543
613
 
544
- if (bitmap == null) {
545
- return null
546
- }
547
-
548
- assetImage.color?.get(context)?.let { color ->
549
- val result = createBitmap(bitmap.width, bitmap.height)
550
- val canvas = Canvas(result)
614
+ private fun applyTintAndCache(
615
+ context: CarContext,
616
+ color: NitroColor?,
617
+ bitmap: Bitmap,
618
+ cache: (Bitmap) -> Unit
619
+ ): Bitmap {
620
+ color?.get(context)?.let { tint ->
621
+ val tinted = createBitmap(bitmap.width, bitmap.height)
622
+ val canvas = Canvas(tinted)
551
623
  val paint = Paint()
552
624
 
553
- paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
625
+ paint.colorFilter = PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_IN)
554
626
  canvas.drawBitmap(bitmap, 0f, 0f, paint)
555
627
 
556
- BitmapCache.put(context, assetImage, result)
557
-
558
- return result
628
+ cache(tinted)
629
+ return tinted
559
630
  }
560
631
 
561
- BitmapCache.put(context, assetImage, bitmap)
562
-
632
+ cache(bitmap)
563
633
  return bitmap
564
634
  }
565
635