@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
@@ -6,6 +6,7 @@ import androidx.car.app.CarContext
6
6
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.AssetImage
7
7
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.GlyphImage
8
8
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroColor
9
+ import com.margelo.nitro.swe.iternio.reactnativeautoplay.RemoteImage
9
10
 
10
11
  object BitmapCache {
11
12
  private val maxMemory = Runtime.getRuntime().maxMemory()
@@ -37,6 +38,16 @@ object BitmapCache {
37
38
  put(key, bitmap)
38
39
  }
39
40
 
41
+ fun get(context: CarContext, image: RemoteImage): Bitmap? {
42
+ val key = image.cacheKey(context)
43
+ return get(key)
44
+ }
45
+
46
+ fun put(context: CarContext, image: RemoteImage, bitmap: Bitmap) {
47
+ val key = image.cacheKey(context)
48
+ put(key, bitmap)
49
+ }
50
+
40
51
  private fun get(key: String): Bitmap? {
41
52
  synchronized(bitmapCache) {
42
53
  return bitmapCache.get(key)
@@ -56,5 +67,8 @@ fun NitroColor.get(context: CarContext): Int =
56
67
  fun AssetImage.cacheKey(context: CarContext): String =
57
68
  this.color?.let { "${this.uri}/${it.get(context)}" } ?: run { this.uri }
58
69
 
70
+ fun RemoteImage.cacheKey(context: CarContext): String =
71
+ this.color?.let { "${this.uri}/${it.get(context)}" } ?: run { this.uri }
72
+
59
73
  fun GlyphImage.cacheKey(context: CarContext): String =
60
74
  "$glyph/${color.get(context)}/${backgroundColor.get(context)}/$fontScale"
@@ -8,6 +8,7 @@
8
8
  protocol ImageProtocol {
9
9
  var glyphImage: GlyphImage? { get }
10
10
  var assetImage: AssetImage? { get }
11
+ var remoteImage: RemoteImage? { get }
11
12
  }
12
13
 
13
14
  extension NitroImage: ImageProtocol {
@@ -19,9 +20,13 @@ extension NitroImage: ImageProtocol {
19
20
  if case .second(let asset) = self { return asset }
20
21
  return nil
21
22
  }
23
+ var remoteImage: RemoteImage? {
24
+ if case .third(let remote) = self { return remote }
25
+ return nil
26
+ }
22
27
  }
23
28
 
24
- extension Variant_GlyphImage_AssetImage: ImageProtocol {
29
+ extension Variant_GlyphImage_AssetImage_RemoteImage: ImageProtocol {
25
30
  var glyphImage: GlyphImage? {
26
31
  if case .first(let glyph) = self { return glyph }
27
32
  return nil
@@ -30,4 +35,8 @@ extension Variant_GlyphImage_AssetImage: ImageProtocol {
30
35
  if case .second(let asset) = self { return asset }
31
36
  return nil
32
37
  }
38
+ var remoteImage: RemoteImage? {
39
+ if case .third(let remote) = self { return remote }
40
+ return nil
41
+ }
33
42
  }
@@ -1,3 +1,4 @@
1
+ import AVFoundation
1
2
  import CarPlay
2
3
  import NitroModules
3
4
 
@@ -20,6 +21,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
20
21
  private static var listeners = [EventName: [StateListener]]()
21
22
  private static var renderStateListeners = [String: [RenderStateListener]]()
22
23
  private static var safeAreaInsetsListeners = [String: [SafeAreaListener]]()
24
+ private static var voiceInputManager: VoiceInputManager?
23
25
 
24
26
  override init() {
25
27
  HybridAutoPlay.listeners.removeAll()
@@ -117,10 +119,55 @@ class HybridAutoPlay: HybridAutoPlaySpec {
117
119
  func addListenerVoiceInput(
118
120
  callback: @escaping (Location?, String?) -> Void
119
121
  ) throws -> () -> Void {
120
- // TODO: Inplement voice input
122
+ // iOS does not use the OS-triggered voice input path — use startVoiceInput() instead.
121
123
  return {}
122
124
  }
123
125
 
126
+ func hasVoiceInputPermission() throws -> Bool {
127
+ return AVAudioSession.sharedInstance().recordPermission == .granted
128
+ }
129
+
130
+ func requestVoiceInputPermission() throws -> Promise<Bool> {
131
+ return Promise.async {
132
+ return await withCheckedContinuation { cont in
133
+ AVAudioSession.sharedInstance().requestRecordPermission { granted in
134
+ cont.resume(returning: granted)
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ func startVoiceInput(silenceThresholdMs: Double?, maxDurationMs: Double?, listeningText: String?) throws -> Promise<
141
+ ArrayBuffer
142
+ > {
143
+ return Promise.async {
144
+ let interfaceController = try? await RootModule.withInterfaceController { $0 }
145
+
146
+ let manager = VoiceInputManager()
147
+ HybridAutoPlay.voiceInputManager = manager
148
+
149
+ defer {
150
+ HybridAutoPlay.voiceInputManager = nil
151
+ }
152
+
153
+ let data = try await manager.start(
154
+ interfaceController: interfaceController,
155
+ silenceThresholdMs: silenceThresholdMs ?? 1_500,
156
+ maxDurationMs: maxDurationMs ?? 10_000,
157
+ listeningText: listeningText ?? "Listening..."
158
+ )
159
+
160
+ return try ArrayBuffer.copy(data: data)
161
+ }
162
+ }
163
+
164
+ func stopVoiceInput() throws {
165
+ Task { @MainActor in
166
+ let interfaceController = try? await RootModule.withInterfaceController { $0 }
167
+ HybridAutoPlay.voiceInputManager?.stop(interfaceController: interfaceController)
168
+ }
169
+ }
170
+
124
171
  // MARK: set/push/pop templates
125
172
  func setRootTemplate(templateId: String) throws -> Promise<Void> {
126
173
  return Promise.async {
@@ -151,7 +198,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
151
198
  }
152
199
 
153
200
  func pushTemplate(templateId: String) throws
154
- -> NitroModules.Promise<Void>
201
+ -> Promise<Void>
155
202
  {
156
203
  return Promise.async {
157
204
  try await RootModule.withSceneAndInterfaceController {
@@ -201,7 +248,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
201
248
  }
202
249
  }
203
250
 
204
- func popTemplate(animate: Bool?) throws -> NitroModules.Promise<Void> {
251
+ func popTemplate(animate: Bool?) throws -> Promise<Void> {
205
252
  return Promise.async {
206
253
  try await RootModule.withInterfaceController {
207
254
  interfaceController in
@@ -222,7 +269,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
222
269
  }
223
270
  }
224
271
 
225
- func popToRootTemplate(animate: Bool?) throws -> NitroModules.Promise<Void> {
272
+ func popToRootTemplate(animate: Bool?) throws -> Promise<Void> {
226
273
  return Promise.async {
227
274
  try await RootModule.withInterfaceController {
228
275
  interfaceController in
@@ -69,6 +69,13 @@ class GridTemplate: AutoPlayHeaderProviding {
69
69
  )
70
70
  }
71
71
 
72
+ if let remoteImage = button.image.remoteImage {
73
+ image = Parser.parseRemoteImage(
74
+ remoteImage: remoteImage,
75
+ traitCollection: traitCollection
76
+ )
77
+ }
78
+
72
79
  guard let image = image else { return nil }
73
80
  guard let title = Parser.parseText(text: button.title) else { return nil }
74
81
 
@@ -127,6 +127,20 @@ class MapTemplate: AutoPlayHeaderProviding,
127
127
  button.onPress?()
128
128
  }
129
129
  }
130
+ if let remoteImage = button.image.remoteImage,
131
+ let icon = Parser.parseRemoteImage(
132
+ remoteImage: remoteImage,
133
+ traitCollection: traitCollection
134
+ )
135
+ {
136
+ return CPMapButton(image: icon) { _ in
137
+ if button.type == .pan {
138
+ self.onPanButtonPress()
139
+ return
140
+ }
141
+ button.onPress?()
142
+ }
143
+ }
130
144
 
131
145
  return CPMapButton { _ in
132
146
  if button.type == .pan {
@@ -74,6 +74,12 @@ class Parser {
74
74
  traitCollection: traitCollection
75
75
  )
76
76
  }
77
+ if let remoteImage = action.image?.remoteImage {
78
+ image = Parser.parseRemoteImage(
79
+ remoteImage: remoteImage,
80
+ traitCollection: traitCollection
81
+ )
82
+ }
77
83
 
78
84
  var button: CPBarButton
79
85
 
@@ -771,9 +777,30 @@ class Parser {
771
777
  )
772
778
  }
773
779
 
780
+ if let remoteImage = image?.remoteImage {
781
+ return Parser.parseRemoteImage(
782
+ remoteImage: remoteImage,
783
+ traitCollection: traitCollection
784
+ )
785
+ }
786
+
774
787
  return nil
775
788
  }
776
789
 
790
+ // MARK: - Remote image cache
791
+ private static let remoteImageCache: NSCache<NSString, UIImage> = {
792
+ let cache = NSCache<NSString, UIImage>()
793
+ cache.countLimit = 50
794
+ cache.totalCostLimit = 8 * 1024 * 1024 // 8 MB, matching Android's BitmapCache
795
+ return cache
796
+ }()
797
+
798
+ /// Shared session — long-lived by design; failed tasks don't invalidate it.
799
+ private static let remoteImageSession = URLSession(configuration: .default)
800
+
801
+ /// Default network timeout for remote images when no `timeoutMs` is provided.
802
+ private static let defaultRemoteTimeoutSeconds: TimeInterval = 0.5
803
+
777
804
  static func parseAssetImage(
778
805
  assetImage: AssetImage,
779
806
  traitCollection: UITraitCollection
@@ -784,17 +811,77 @@ class Parser {
784
811
  "__packager_asset": assetImage.packager_asset,
785
812
  ])
786
813
 
787
- guard let color = assetImage.color else {
788
- return uiImage
789
- }
814
+ return applyTint(
815
+ uiImage: uiImage,
816
+ color: assetImage.color,
817
+ traitCollection: traitCollection
818
+ )
819
+ }
820
+
821
+ static func parseRemoteImage(
822
+ remoteImage: RemoteImage,
823
+ traitCollection: UITraitCollection
824
+ ) -> UIImage? {
825
+ let timeoutSeconds = remoteImage.timeoutMs.map { $0 / 1000.0 } ?? defaultRemoteTimeoutSeconds
826
+ let uiImage = loadRemoteImage(uri: remoteImage.uri, timeoutSeconds: timeoutSeconds)
827
+
828
+ return applyTint(
829
+ uiImage: uiImage,
830
+ color: remoteImage.color,
831
+ traitCollection: traitCollection
832
+ )
833
+ }
834
+
835
+ private static func applyTint(
836
+ uiImage: UIImage?,
837
+ color: NitroColor?,
838
+ traitCollection: UITraitCollection
839
+ ) -> UIImage? {
840
+ guard let image = uiImage else { return nil }
841
+ guard let color else { return image }
790
842
 
791
843
  return getTintedImageAsset(
792
844
  color: color,
793
- uiImage: uiImage,
845
+ uiImage: image,
794
846
  traitCollection: traitCollection
795
847
  )
796
848
  }
797
849
 
850
+ /// Synchronously loads an image from a remote HTTPS URL with in-memory caching.
851
+ /// `parseRemoteImage` is always invoked on a background thread by the Car App rendering pipeline,
852
+ /// so the semaphore wait cannot block the main thread.
853
+ private static func loadRemoteImage(uri: String, timeoutSeconds: TimeInterval) -> UIImage? {
854
+ let cacheKey = uri as NSString
855
+ if let cached = remoteImageCache.object(forKey: cacheKey) {
856
+ return cached
857
+ }
858
+
859
+ guard let url = URL(string: uri) else { return nil }
860
+
861
+ var request = URLRequest(url: url)
862
+ request.timeoutInterval = timeoutSeconds
863
+
864
+ var resultData: Data?
865
+ let semaphore = DispatchSemaphore(value: 0)
866
+ let task = remoteImageSession.dataTask(with: request) { data, _, _ in
867
+ resultData = data
868
+ semaphore.signal()
869
+ }
870
+ task.resume()
871
+ if semaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
872
+ task.cancel()
873
+ return UIImage(systemName: "exclamationmark.circle")
874
+ }
875
+
876
+ guard let data = resultData, let image = UIImage(data: data) else {
877
+ return UIImage(systemName: "exclamationmark.circle")
878
+ }
879
+
880
+ let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
881
+ remoteImageCache.setObject(image, forKey: cacheKey, cost: cost)
882
+ return image
883
+ }
884
+
798
885
  static func getTintedImageAsset(
799
886
  color: NitroColor,
800
887
  uiImage: UIImage,
@@ -0,0 +1,233 @@
1
+ import AVFoundation
2
+ import CarPlay
3
+
4
+ /// Captures audio from the car microphone and buffers raw 16 kHz / 16-bit / mono PCM.
5
+ /// Recording stops automatically when silence is detected or the max duration is reached.
6
+ class VoiceInputManager {
7
+ private var audioEngine: AVAudioEngine?
8
+ private var voiceControlTemplate: CPVoiceControlTemplate?
9
+ private var continuation: CheckedContinuation<[Int16], Error>?
10
+ private var samples: [Int16] = []
11
+ private var isStopping = false
12
+ private let stopLock = NSLock()
13
+
14
+ // Timing
15
+ private var recordingStart: Date?
16
+ private var silenceStart: Date?
17
+
18
+ private static let sampleRate: Double = 16_000
19
+ private static let tapBufferSize: AVAudioFrameCount = 4_096
20
+ private static let silenceAmplitudeThreshold = 500
21
+ private static let warmupMs: Double = 500
22
+
23
+ private static let targetFormat = AVAudioFormat(
24
+ commonFormat: .pcmFormatInt16,
25
+ sampleRate: sampleRate,
26
+ channels: 1,
27
+ interleaved: true
28
+ )!
29
+
30
+ // MARK: - Public
31
+
32
+ func start(
33
+ interfaceController: AutoPlayInterfaceController?,
34
+ silenceThresholdMs: Double,
35
+ maxDurationMs: Double,
36
+ listeningText: String
37
+ ) async throws -> Data {
38
+ let samples = try await withCheckedThrowingContinuation {
39
+ (cont: CheckedContinuation<[Int16], Error>) in
40
+ self.continuation = cont
41
+ self.samples = []
42
+ self.isStopping = false
43
+
44
+ do {
45
+ try self.startCapture(
46
+ interfaceController: interfaceController,
47
+ silenceThresholdMs: silenceThresholdMs,
48
+ maxDurationMs: maxDurationMs,
49
+ listeningText: listeningText
50
+ )
51
+ }
52
+ catch {
53
+ self.stopCapture(interfaceController: interfaceController)
54
+ self.continuation = nil
55
+ cont.resume(throwing: error)
56
+ }
57
+ }
58
+
59
+ return samplesAsData(samples)
60
+ }
61
+
62
+ func stop(interfaceController: AutoPlayInterfaceController? = nil) {
63
+ stopLock.lock()
64
+ guard !isStopping else {
65
+ stopLock.unlock()
66
+ return
67
+ }
68
+ isStopping = true
69
+ let capturedContinuation = continuation
70
+ let capturedSamples = samples
71
+ continuation = nil
72
+ samples = []
73
+ stopLock.unlock()
74
+
75
+ stopCapture(interfaceController: interfaceController)
76
+ capturedContinuation?.resume(returning: capturedSamples)
77
+ }
78
+
79
+ // MARK: - Private
80
+
81
+ private func startCapture(
82
+ interfaceController: AutoPlayInterfaceController?,
83
+ silenceThresholdMs: Double,
84
+ maxDurationMs: Double,
85
+ listeningText: String
86
+ ) throws {
87
+ guard AVAudioSession.sharedInstance().recordPermission == .granted else {
88
+ throw VoiceInputError.microphonePermissionDenied
89
+ }
90
+
91
+ // Activate the session first so inputNode reports the correct hardware format
92
+ let session = AVAudioSession.sharedInstance()
93
+ try session.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers])
94
+ try session.setActive(true)
95
+
96
+ if let interfaceController {
97
+ presentVoiceTemplate(interfaceController: interfaceController, listeningText: listeningText)
98
+ }
99
+
100
+ let engine = AVAudioEngine()
101
+ let inputNode = engine.inputNode
102
+ let nativeFormat = inputNode.outputFormat(forBus: 0)
103
+
104
+ let targetFormat = VoiceInputManager.targetFormat
105
+ guard let converter = AVAudioConverter(from: nativeFormat, to: targetFormat) else {
106
+ throw VoiceInputError.converterUnavailable
107
+ }
108
+
109
+ recordingStart = Date()
110
+ silenceStart = nil
111
+
112
+ inputNode.installTap(
113
+ onBus: 0,
114
+ bufferSize: VoiceInputManager.tapBufferSize,
115
+ format: nativeFormat
116
+ ) { [weak self] buffer, _ in
117
+ guard let self, !self.isStopping else { return }
118
+
119
+ let outputFrameCapacity = AVAudioFrameCount(
120
+ Double(buffer.frameLength)
121
+ * VoiceInputManager.sampleRate
122
+ / nativeFormat.sampleRate
123
+ )
124
+
125
+ guard
126
+ let outputBuffer = AVAudioPCMBuffer(
127
+ pcmFormat: targetFormat,
128
+ frameCapacity: outputFrameCapacity
129
+ )
130
+ else { return }
131
+
132
+ var conversionError: NSError?
133
+ let status = converter.convert(to: outputBuffer, error: &conversionError) {
134
+ _,
135
+ outStatus in
136
+ outStatus.pointee = .haveData
137
+ return buffer
138
+ }
139
+
140
+ guard status != .error, let int16Data = outputBuffer.int16ChannelData else { return }
141
+
142
+ let frameCount = Int(outputBuffer.frameLength)
143
+ let newSamples = Array(UnsafeBufferPointer(start: int16Data[0], count: frameCount))
144
+ self.samples.append(contentsOf: newSamples)
145
+
146
+ let now = Date()
147
+
148
+ // Max duration check
149
+ if let start = self.recordingStart,
150
+ now.timeIntervalSince(start) * 1000 >= maxDurationMs
151
+ {
152
+ self.triggerAutoStop(interfaceController: interfaceController)
153
+ return
154
+ }
155
+
156
+ // Silence detection — skip during warm-up so the pipeline has time
157
+ // to stabilise before we start measuring amplitude
158
+ if let start = self.recordingStart,
159
+ now.timeIntervalSince(start) * 1000 >= VoiceInputManager.warmupMs
160
+ {
161
+ let peak = newSamples.reduce(0) { max($0, abs(Int($1))) }
162
+ if peak < VoiceInputManager.silenceAmplitudeThreshold {
163
+ if self.silenceStart == nil { self.silenceStart = now }
164
+ if let silenceBegin = self.silenceStart,
165
+ now.timeIntervalSince(silenceBegin) * 1000 >= silenceThresholdMs
166
+ {
167
+ self.triggerAutoStop(interfaceController: interfaceController)
168
+ }
169
+ }
170
+ else {
171
+ self.silenceStart = nil
172
+ }
173
+ }
174
+ }
175
+
176
+ try engine.start()
177
+ audioEngine = engine
178
+ }
179
+
180
+ private func triggerAutoStop(interfaceController: AutoPlayInterfaceController?) {
181
+ DispatchQueue.global(qos: .userInitiated).async {
182
+ self.stop(interfaceController: interfaceController)
183
+ }
184
+ }
185
+
186
+ private func stopCapture(interfaceController: AutoPlayInterfaceController?) {
187
+ audioEngine?.inputNode.removeTap(onBus: 0)
188
+ audioEngine?.stop()
189
+ audioEngine = nil
190
+ recordingStart = nil
191
+ silenceStart = nil
192
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
193
+ if let interfaceController {
194
+ dismissVoiceTemplate(interfaceController: interfaceController)
195
+ }
196
+ }
197
+
198
+ private func presentVoiceTemplate(interfaceController: AutoPlayInterfaceController, listeningText: String) {
199
+ let listeningState = CPVoiceControlState(
200
+ identifier: "listening",
201
+ titleVariants: [listeningText],
202
+ image: nil,
203
+ repeats: true
204
+ )
205
+ let template = CPVoiceControlTemplate(voiceControlStates: [listeningState])
206
+ initTemplate(template: template, id: "voice-input")
207
+ voiceControlTemplate = template
208
+
209
+ Task { @MainActor in
210
+ try? await interfaceController.presentTemplate(template, animated: true)
211
+ template.activateVoiceControlState(withIdentifier: "listening")
212
+ }
213
+ }
214
+
215
+ private func dismissVoiceTemplate(interfaceController: AutoPlayInterfaceController) {
216
+ Task { @MainActor in
217
+ try? await interfaceController.dismissTemplate(animated: true)
218
+ }
219
+ voiceControlTemplate = nil
220
+ }
221
+
222
+ private func samplesAsData(_ samples: [Int16]) -> Data {
223
+ samples.withUnsafeBufferPointer { ptr in
224
+ Data(buffer: ptr)
225
+ }
226
+ }
227
+ }
228
+
229
+ enum VoiceInputError: Error {
230
+ case microphonePermissionDenied
231
+ case converterUnavailable
232
+ case noActiveSession
233
+ }
@@ -24,12 +24,42 @@ export interface AutoPlay extends HybridObject<{
24
24
  */
25
25
  addListenerRenderState(moduleName: string, callback: (payload: VisibilityState) => void): CleanupCallback;
26
26
  /**
27
- * Adds a listener for voice input events. Not implemented on iOS.
27
+ * Adds a listener for voice input events fired by the OS (Android Auto only).
28
+ * On iOS this is a no-op — use startVoiceInput instead.
28
29
  * @param callback the callback to receive the voice input
29
30
  * @returns callback to remove the listener
30
31
  * @namespace Android
31
32
  */
32
33
  addListenerVoiceInput(callback: (coordinates: Location | undefined, query: string | undefined) => void): CleanupCallback;
34
+ /**
35
+ * Returns true if microphone permission has already been granted.
36
+ */
37
+ hasVoiceInputPermission(): boolean;
38
+ /**
39
+ * Request microphone permission from the user.
40
+ * On Android: uses the car context when Android Auto is connected, otherwise
41
+ * falls back to the React Native application context.
42
+ * On iOS: uses AVAudioApplication (iOS 17+) or AVAudioSession (iOS 15–16).
43
+ * Returns true if permission was granted, false if denied.
44
+ */
45
+ requestVoiceInputPermission(): Promise<boolean>;
46
+ /**
47
+ * Start an in-app voice recording session.
48
+ * On Android: acquires audio focus and captures via CarAudioRecord when
49
+ * Android Auto is connected, otherwise uses standard AudioRecord.
50
+ * On iOS: presents CPVoiceControlTemplate (when a car is connected) and
51
+ * captures audio via AVAudioEngine.
52
+ * Resolves with the complete raw PCM buffer (16 kHz, 16-bit, mono) when
53
+ * silence is detected, the max duration is reached, or stopVoiceInput() is called.
54
+ * Rejects if microphone permission has not been granted or recording fails to start.
55
+ */
56
+ startVoiceInput(silenceThresholdMs?: number, maxDurationMs?: number, listeningText?: string): Promise<ArrayBuffer>;
57
+ /**
58
+ * Stop the active voice recording session early. Causes the Promise returned
59
+ * by startVoiceInput() to resolve with the audio captured so far.
60
+ * No-op if no recording is in progress.
61
+ */
62
+ stopVoiceInput(): void;
33
63
  /**
34
64
  * sets the specified template as root template, initializes a new stack
35
65
  * Promise might contain an error message in case setting root template failed
@@ -24,4 +24,17 @@ export type AutoImage = {
24
24
  */
25
25
  color?: ThemedColor | string;
26
26
  type: 'asset';
27
+ } | {
28
+ /** HTTPS URL to a remote image. HTTP is not supported (blocked by App Transport Security). */
29
+ uri: string;
30
+ /**
31
+ * if specified the image gets tinted, if not it will just use the original image
32
+ */
33
+ color?: ThemedColor | string;
34
+ /**
35
+ * Network timeout in milliseconds before the remote fetch is abandoned and `null` is returned.
36
+ * Defaults to 500ms when not specified.
37
+ */
38
+ timeoutMs?: number;
39
+ type: 'remote';
27
40
  };
@@ -11,11 +11,16 @@ interface GlyphImage {
11
11
  backgroundColor: NitroColor;
12
12
  fontScale?: number;
13
13
  }
14
+ interface RemoteImage {
15
+ uri: string;
16
+ color?: NitroColor;
17
+ timeoutMs?: number;
18
+ }
14
19
  /**
15
20
  * we need to map the ButtonImage.name from GlyphName to
16
21
  * the actual numeric value so we need a nitro specific type
17
22
  */
18
- export type NitroImage = GlyphImage | AssetImage;
23
+ export type NitroImage = GlyphImage | AssetImage | RemoteImage;
19
24
  declare function convert(image: AutoImage): NitroImage;
20
25
  declare function convert(image?: AutoImage): NitroImage | undefined;
21
26
  export declare const NitroImageUtil: {
@@ -14,6 +14,13 @@ function convert(image) {
14
14
  fontScale,
15
15
  };
16
16
  }
17
+ if (image.type === 'remote') {
18
+ return {
19
+ uri: image.uri,
20
+ color: NitroColorUtil.convert(image.color),
21
+ timeoutMs: image.timeoutMs,
22
+ };
23
+ }
17
24
  // Image.resolveAssetSource is pretty terrible, it will simply return whatever object you pass it is not a number [require(...)]
18
25
  // so the input allows all optional parameters which are returned as is even though
19
26
  // the return type claims to not have any optional parameters...
@@ -49,7 +49,7 @@ target_sources(
49
49
  ../nitrogen/generated/android/c++/JHybridAndroidAutomotiveSpec.cpp
50
50
  ../nitrogen/generated/android/c++/JHybridAndroidAutoTelemetrySpec.cpp
51
51
  ../nitrogen/generated/android/c++/JHybridAutoPlaySpec.cpp
52
- ../nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage.cpp
52
+ ../nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.cpp
53
53
  ../nitrogen/generated/android/c++/JHybridClusterSpec.cpp
54
54
  ../nitrogen/generated/android/c++/JNitroImage.cpp
55
55
  ../nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp
@@ -33,7 +33,8 @@
33
33
  #include "JNitroImage.hpp"
34
34
  #include "JNitroMapButton.hpp"
35
35
  #include "JNitroMapButtonType.hpp"
36
- #include "JVariant_GlyphImage_AssetImage.hpp"
36
+ #include "JRemoteImage.hpp"
37
+ #include "JVariant_GlyphImage_AssetImage_RemoteImage.hpp"
37
38
  #include "NitroAction.hpp"
38
39
  #include "NitroActionType.hpp"
39
40
  #include "NitroAlignment.hpp"
@@ -43,6 +44,7 @@
43
44
  #include "NitroGridButton.hpp"
44
45
  #include "NitroMapButton.hpp"
45
46
  #include "NitroMapButtonType.hpp"
47
+ #include "RemoteImage.hpp"
46
48
  #include <NitroModules/JNICallable.hpp>
47
49
  #include <functional>
48
50
  #include <optional>