react-native-audio-api 0.13.4 → 0.13.6

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 (65) hide show
  1. package/android/src/main/cpp/audioapi/android/OnLoad.cpp +5 -1
  2. package/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +26 -17
  3. package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.cpp +41 -0
  4. package/android/src/main/cpp/audioapi/android/system/NativeRecorderControl.h +23 -0
  5. package/android/src/main/java/com/swmansion/audioapi/system/AudioEvent.kt +1 -0
  6. package/android/src/main/java/com/swmansion/audioapi/system/CentralizedForegroundService.kt +136 -30
  7. package/android/src/main/java/com/swmansion/audioapi/system/ForegroundServiceManager.kt +47 -12
  8. package/android/src/main/java/com/swmansion/audioapi/system/MediaSessionManager.kt +23 -0
  9. package/android/src/main/java/com/swmansion/audioapi/system/NativeRecorderControl.kt +39 -0
  10. package/android/src/main/java/com/swmansion/audioapi/system/RecorderState.kt +17 -0
  11. package/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +48 -20
  12. package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotification.kt +221 -227
  13. package/android/src/main/java/com/swmansion/audioapi/system/notification/RecordingNotificationReceiver.kt +122 -9
  14. package/android/src/main/java/com/swmansion/audioapi/system/notification/state/RecordingNotificationState.kt +17 -11
  15. package/common/cpp/audioapi/AudioAPIModuleInstaller.h +44 -0
  16. package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +18 -11
  17. package/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +1 -0
  18. package/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +2 -0
  19. package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.cpp +117 -0
  20. package/common/cpp/audioapi/core/inputs/ActiveRecorderHandle.h +85 -0
  21. package/common/cpp/audioapi/core/inputs/AudioRecorder.cpp +8 -0
  22. package/common/cpp/audioapi/core/inputs/AudioRecorder.h +3 -1
  23. package/common/cpp/audioapi/core/inputs/RecorderState.h +13 -0
  24. package/common/cpp/audioapi/core/sources/RecorderAdapterNode.h +0 -1
  25. package/common/cpp/audioapi/core/utils/AudioRecorderCallback.cpp +4 -4
  26. package/common/cpp/audioapi/core/utils/AudioRecorderCallback.h +2 -4
  27. package/common/cpp/audioapi/events/AudioEvent.h +1 -0
  28. package/ios/audioapi/ios/AudioAPIModule.mm +30 -23
  29. package/ios/audioapi/ios/core/IOSAudioRecorder.mm +27 -29
  30. package/ios/audioapi/ios/core/NativeAudioRecorder.h +1 -1
  31. package/ios/audioapi/ios/core/NativeAudioRecorder.m +9 -7
  32. package/ios/audioapi/ios/core/utils/FileOptions.mm +18 -5
  33. package/ios/audioapi/ios/core/utils/IOSFileWriter.h +5 -0
  34. package/ios/audioapi/ios/core/utils/IOSFileWriter.mm +50 -20
  35. package/ios/audioapi/ios/core/utils/IOSRotatingFileWriter.mm +2 -7
  36. package/ios/audioapi/ios/system/SystemNotificationManager.mm +4 -1
  37. package/lib/commonjs/core/AudioRecorder.js +12 -4
  38. package/lib/commonjs/core/AudioRecorder.js.map +1 -1
  39. package/lib/commonjs/mock/index.js +11 -1
  40. package/lib/commonjs/mock/index.js.map +1 -1
  41. package/lib/commonjs/plugin/withAudioAPI.js +4 -2
  42. package/lib/commonjs/plugin/withAudioAPI.js.map +1 -1
  43. package/lib/module/core/AudioRecorder.js +12 -4
  44. package/lib/module/core/AudioRecorder.js.map +1 -1
  45. package/lib/module/mock/index.js +11 -1
  46. package/lib/module/mock/index.js.map +1 -1
  47. package/lib/module/plugin/withAudioAPI.js +4 -2
  48. package/lib/module/plugin/withAudioAPI.js.map +1 -1
  49. package/lib/typescript/core/AudioRecorder.d.ts +2 -0
  50. package/lib/typescript/core/AudioRecorder.d.ts.map +1 -1
  51. package/lib/typescript/mock/index.d.ts +3 -0
  52. package/lib/typescript/mock/index.d.ts.map +1 -1
  53. package/lib/typescript/plugin/withAudioAPI.d.ts +1 -0
  54. package/lib/typescript/plugin/withAudioAPI.d.ts.map +1 -1
  55. package/lib/typescript/system/notification/types.d.ts +32 -2
  56. package/lib/typescript/system/notification/types.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/AudioAPIModule/globals.d.ts +5 -0
  59. package/src/core/AudioRecorder.ts +17 -4
  60. package/src/mock/index.ts +14 -1
  61. package/src/plugin/withAudioAPI.ts +4 -2
  62. package/src/system/notification/types.ts +32 -2
  63. package/android/src/main/res/layout/btn_round_ripple.xml +0 -9
  64. package/android/src/main/res/layout/notification_collapsed.xml +0 -45
  65. package/android/src/main/res/layout/notification_expanded.xml +0 -44
@@ -1,5 +1,6 @@
1
1
  package com.swmansion.audioapi.system.notification
2
2
 
3
+ import android.annotation.SuppressLint
3
4
  import android.app.Notification
4
5
  import android.util.Log
5
6
  import androidx.annotation.RequiresPermission
@@ -8,6 +9,7 @@ import com.facebook.react.bridge.ReactApplicationContext
8
9
  import com.facebook.react.bridge.ReadableMap
9
10
  import com.swmansion.audioapi.system.ForegroundServiceManager
10
11
  import java.lang.ref.WeakReference
12
+ import java.util.concurrent.ConcurrentHashMap
11
13
 
12
14
  /**
13
15
  * Central notification registry that manages multiple notification instances.
@@ -20,8 +22,9 @@ class NotificationRegistry(
20
22
  companion object {
21
23
  private const val TAG = "NotificationRegistry"
22
24
 
23
- // Store last built notifications for foreground service access
24
- private val builtNotifications = mutableMapOf<Int, Notification>()
25
+ // Store last built notifications for foreground service access. Concurrent because
26
+ // CentralizedForegroundService reads it on its main thread, outside the registry monitor.
27
+ private val builtNotifications = ConcurrentHashMap<Int, Notification>()
25
28
 
26
29
  fun getBuiltNotification(notificationId: Int): Notification? = builtNotifications[notificationId]
27
30
  }
@@ -39,6 +42,7 @@ class NotificationRegistry(
39
42
  * @param type The type of notification (only used for first creation)
40
43
  * @param options Configuration options from JavaScript
41
44
  */
45
+ @Synchronized
42
46
  @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS)
43
47
  fun showNotification(
44
48
  key: String,
@@ -76,11 +80,7 @@ class NotificationRegistry(
76
80
  }
77
81
  }
78
82
 
79
- /**
80
- * Hide a notification.
81
- *
82
- * @param key The unique identifier of the notification
83
- */
83
+ @Synchronized
84
84
  fun hideNotification(key: String) {
85
85
  val notification = notifications[key]
86
86
  if (notification == null) {
@@ -88,23 +88,46 @@ class NotificationRegistry(
88
88
  return
89
89
  }
90
90
 
91
- try {
92
- // Only hide if currently active
93
- if (activeNotifications.getOrDefault(key, false)) {
94
- cancelNotification(notification.getNotificationId())
95
- notification.hide()
96
- activeNotifications[key] = false
97
-
98
- // Unsubscribe from foreground service
99
- ForegroundServiceManager.unsubscribe(notification)
91
+ // Only hide if currently active
92
+ if (!activeNotifications.getOrDefault(key, false)) {
93
+ return
94
+ }
100
95
 
101
- Log.d(TAG, "Hiding notification: $key (unsubscribed from foreground service)")
102
- }
96
+ try {
97
+ cancelNotification(notification.getNotificationId())
98
+ notification.hide()
103
99
  } catch (e: Exception) {
104
100
  Log.e(TAG, "Error hiding notification $key: ${e.message}", e)
101
+ } finally {
102
+ activeNotifications[key] = false
103
+ ForegroundServiceManager.unsubscribe(notification)
105
104
  }
106
105
  }
107
106
 
107
+ @Synchronized
108
+ fun hideNotification(id: Int) {
109
+ notifications.entries
110
+ .firstOrNull { it.value.getNotificationId() == id }
111
+ ?.let { hideNotification(it.key) }
112
+ }
113
+
114
+ /**
115
+ * Rebuild and re-post the recording notification with a new paused state.
116
+ */
117
+ @Synchronized
118
+ @SuppressLint("MissingPermission")
119
+ fun updateRecordingNotificationPausedState(paused: Boolean) {
120
+ val entry =
121
+ notifications.entries.firstOrNull {
122
+ it.value.getNotificationId() == RecordingNotification.ID
123
+ } ?: return
124
+ if (!activeNotifications.getOrDefault(entry.key, false)) {
125
+ return
126
+ }
127
+ val recordingNotification = entry.value as? RecordingNotification ?: return
128
+ displayNotification(RecordingNotification.ID, recordingNotification.rebuildWithPausedState(paused))
129
+ }
130
+
108
131
  /**
109
132
  * Create a notification instance.
110
133
  *
@@ -149,6 +172,7 @@ class NotificationRegistry(
149
172
  *
150
173
  * @param key The unique identifier of the notification
151
174
  */
175
+ @Synchronized
152
176
  fun destroyNotification(key: String) {
153
177
  hideNotification(key)
154
178
  notifications.remove(key)
@@ -159,16 +183,19 @@ class NotificationRegistry(
159
183
  /**
160
184
  * Check if a notification is currently active.
161
185
  */
186
+ @Synchronized
162
187
  fun isNotificationActive(key: String): Boolean = activeNotifications.getOrDefault(key, false)
163
188
 
164
189
  /**
165
190
  * Get all registered notification keys.
166
191
  */
192
+ @Synchronized
167
193
  fun getRegisteredKeys(): Set<String> = notifications.keys.toSet()
168
194
 
169
195
  /**
170
196
  * Cleanup all notifications.
171
197
  */
198
+ @Synchronized
172
199
  fun cleanup() {
173
200
  notifications.keys.toList().forEach { key ->
174
201
  hideNotification(key)
@@ -202,9 +229,10 @@ class NotificationRegistry(
202
229
  }
203
230
 
204
231
  private fun cancelNotification(id: Int) {
232
+ // Drop the stored notification first so the foreground service can no longer pick
233
+ // it up, even when the released React context prevents the system-side cancel.
234
+ builtNotifications.remove(id)
205
235
  val context = reactContext.get() ?: return
206
236
  NotificationManagerCompat.from(context).cancel(id)
207
- // Clean up stored notification
208
- builtNotifications.remove(id)
209
237
  }
210
238
  }
@@ -4,23 +4,18 @@ import android.app.Notification
4
4
  import android.app.NotificationChannel
5
5
  import android.app.NotificationManager
6
6
  import android.app.PendingIntent
7
- import android.content.ComponentCallbacks
8
7
  import android.content.Context
9
8
  import android.content.Intent
10
9
  import android.content.IntentFilter
11
- import android.content.res.Configuration
12
- import android.graphics.Color
13
10
  import android.graphics.drawable.Icon
11
+ import android.net.Uri
14
12
  import android.os.Build
15
13
  import android.util.Log
16
- import android.widget.RemoteViews
17
- import androidx.annotation.RequiresApi
18
14
  import androidx.core.app.NotificationCompat
19
15
  import androidx.core.content.ContextCompat
20
16
  import com.facebook.react.bridge.ReactApplicationContext
21
17
  import com.facebook.react.bridge.ReadableMap
22
18
  import com.swmansion.audioapi.AudioAPIModule
23
- import com.swmansion.audioapi.R
24
19
  import com.swmansion.audioapi.system.notification.state.RecordingNotificationState
25
20
  import java.lang.ref.WeakReference
26
21
 
@@ -29,196 +24,242 @@ class RecordingNotification(
29
24
  private val audioAPIModule: WeakReference<AudioAPIModule>,
30
25
  private val notificationId: Int,
31
26
  private val channelId: String,
32
- ) : BaseNotification,
33
- ComponentCallbacks {
27
+ ) : BaseNotification {
34
28
  companion object {
35
29
  private const val TAG = "RecordingNotification"
36
30
  const val ID = 200
31
+
32
+ private const val REQUEST_CODE_CONTENT = 2000
33
+ private const val REQUEST_CODE_PAUSE = 2001
34
+ private const val REQUEST_CODE_RESUME = 2002
35
+ private const val REQUEST_CODE_STOP = 2003
36
+ private const val REQUEST_CODE_DISMISSED = 2004
37
37
  }
38
38
 
39
- private var state: RecordingNotificationState =
40
- RecordingNotificationState(
41
- darkTheme =
42
- reactContext
43
- .get()!!
44
- .resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES,
45
- initialized = false,
46
- )
39
+ private val state = RecordingNotificationState()
47
40
 
48
41
  private fun initializeNotification() {
49
42
  val context = reactContext.get() ?: throw IllegalStateException("React context is null")
50
- if (!state.initialized) {
51
- context.registerComponentCallbacks(this)
52
- createNotificationChannel(context)
53
- state.receiver =
54
- RecordingNotificationReceiver(audioAPIModule.get()!!)
55
- val filter =
56
- IntentFilter().apply {
57
- addAction(RecordingNotificationReceiver.NOTIFICATION_RECORDING_STOPPED)
58
- addAction(RecordingNotificationReceiver.NOTIFICATION_RECORDING_RESUMED)
59
- }
60
- ContextCompat.registerReceiver(
61
- context,
62
- state.receiver,
63
- filter,
64
- ContextCompat.RECEIVER_NOT_EXPORTED,
65
- )
66
-
67
- state.pauseIntent =
68
- Intent(RecordingNotificationReceiver.NOTIFICATION_RECORDING_STOPPED).apply {
69
- `package` = context.packageName
70
- }
71
-
72
- state.resumeIntent =
73
- Intent(RecordingNotificationReceiver.NOTIFICATION_RECORDING_RESUMED).apply {
74
- `package` = context.packageName
75
- }
76
- state.darkTheme = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
77
- state.initialized = true
43
+ if (state.initialized) {
44
+ return
78
45
  }
46
+
47
+ createNotificationChannel(context)
48
+ state.receiver = RecordingNotificationReceiver(audioAPIModule.get()!!)
49
+ val filter =
50
+ IntentFilter().apply {
51
+ addAction(RecordingNotificationReceiver.ACTION_PAUSE)
52
+ addAction(RecordingNotificationReceiver.ACTION_RESUME)
53
+ addAction(RecordingNotificationReceiver.ACTION_STOP)
54
+ addAction(RecordingNotificationReceiver.ACTION_DISMISSED)
55
+ }
56
+ ContextCompat.registerReceiver(
57
+ context,
58
+ state.receiver,
59
+ filter,
60
+ ContextCompat.RECEIVER_NOT_EXPORTED,
61
+ )
62
+ state.initialized = true
79
63
  }
80
64
 
81
65
  override fun show(options: ReadableMap?): Notification {
82
66
  initializeNotification()
83
67
  val context = reactContext.get() ?: throw IllegalStateException("React context is null")
84
- if (options != state.cachedRNOptions) {
68
+ if (options !== state.cachedRNOptions) {
85
69
  state.cachedRNOptions = options
86
70
  parseMapFromRN(options)
87
71
  }
88
- val builder = getBuilder()
72
+ return buildNotification(context)
73
+ }
89
74
 
90
- if (state.smallIconResourceName != null) {
91
- builder.setSmallIcon(context.resources.getIdentifier(state.smallIconResourceName, "drawable", context.packageName))
92
- }
75
+ /**
76
+ * Rebuilds with an updated paused flag, leaving the sticky RN options untouched.
77
+ */
78
+ fun rebuildWithPausedState(paused: Boolean): Notification {
79
+ val context = reactContext.get() ?: throw IllegalStateException("React context is null")
80
+ state.paused = paused
81
+ return buildNotification(context)
82
+ }
93
83
 
94
- if (state.largeIconResourceName != null) {
95
- val icon =
96
- Icon.createWithResource(
97
- context,
98
- context.resources.getIdentifier(state.largeIconResourceName, "drawable", context.packageName),
84
+ private fun buildNotification(context: ReactApplicationContext): Notification {
85
+ val builder =
86
+ NotificationCompat
87
+ .Builder(context, channelId)
88
+ .setOngoing(!state.dismissible)
89
+ .setOnlyAlertOnce(true)
90
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
91
+ .setContentTitle(state.title)
92
+ .setContentText(state.contentText)
93
+ .setSmallIcon(
94
+ resolveDrawable(context, state.smallIconResourceName) ?: android.R.drawable.ic_btn_speak_now,
99
95
  )
100
- builder.setLargeIcon(icon)
101
- }
102
96
 
103
- if (state.backgroundColor != null) {
104
- builder.setColor(state.backgroundColor!!)
97
+ resolveDrawable(context, state.largeIconResourceName)?.let {
98
+ builder.setLargeIcon(Icon.createWithResource(context, it))
105
99
  }
100
+ state.backgroundColor?.let { builder.setColor(it) }
106
101
 
107
- val collapsedView = RemoteViews(context.packageName, R.layout.notification_collapsed)
108
- val expandedView = RemoteViews(context.packageName, R.layout.notification_expanded)
102
+ setupContentIntent(context, builder)
103
+ setupActions(context, builder)
104
+ setupDeleteIntent(context, builder)
105
+ setupChronometer(builder)
109
106
 
110
- val (pauseResumePendingIntent, iconId) = setupPauseResumeIntent(context)
107
+ return builder.build()
108
+ }
111
109
 
112
- setupRemoteView(listOf(collapsedView, expandedView), pauseResumePendingIntent, iconId)
110
+ private fun setupContentIntent(
111
+ context: Context,
112
+ builder: NotificationCompat.Builder,
113
+ ) {
114
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return
115
+ state.deepLinkUri?.let {
116
+ launchIntent.action = Intent.ACTION_VIEW
117
+ launchIntent.removeCategory(Intent.CATEGORY_LAUNCHER)
118
+ launchIntent.data = Uri.parse(it)
119
+ }
120
+ builder.setContentIntent(
121
+ PendingIntent.getActivity(
122
+ context,
123
+ REQUEST_CODE_CONTENT,
124
+ launchIntent,
125
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
126
+ ),
127
+ )
128
+ }
113
129
 
114
- builder
115
- .setStyle(NotificationCompat.DecoratedCustomViewStyle())
116
- .setCustomContentView(collapsedView)
117
- .setCustomBigContentView(expandedView)
118
- .setContentTitle(state.title)
119
- .setContentText(state.contentText)
130
+ private fun setupActions(
131
+ context: Context,
132
+ builder: NotificationCompat.Builder,
133
+ ) {
134
+ if (state.paused) {
135
+ builder.addAction(
136
+ createAction(
137
+ context,
138
+ RecordingNotificationReceiver.ACTION_RESUME,
139
+ REQUEST_CODE_RESUME,
140
+ state.resumeActionTitle ?: "Resume",
141
+ ),
142
+ )
143
+ } else {
144
+ builder.addAction(
145
+ createAction(
146
+ context,
147
+ RecordingNotificationReceiver.ACTION_PAUSE,
148
+ REQUEST_CODE_PAUSE,
149
+ state.pauseActionTitle ?: "Pause",
150
+ ),
151
+ )
152
+ }
120
153
 
121
- if (state.backgroundColor != null) {
122
- builder.setColor(state.backgroundColor!!)
154
+ if (state.showStopAction) {
155
+ builder.addAction(
156
+ createAction(
157
+ context,
158
+ RecordingNotificationReceiver.ACTION_STOP,
159
+ REQUEST_CODE_STOP,
160
+ state.stopActionTitle ?: "Stop",
161
+ ),
162
+ )
123
163
  }
164
+ }
124
165
 
125
- return builder.build()
166
+ private fun setupDeleteIntent(
167
+ context: Context,
168
+ builder: NotificationCompat.Builder,
169
+ ) {
170
+ val dismissed =
171
+ Intent(RecordingNotificationReceiver.ACTION_DISMISSED)
172
+ .putExtra(RecordingNotificationReceiver.EXTRA_DISMISS_STOPS_RECORDING, state.dismissible)
173
+ builder.setDeleteIntent(createBroadcast(context, dismissed, REQUEST_CODE_DISMISSED))
126
174
  }
127
175
 
128
- private fun setupPauseResumeIntent(context: Context): Pair<PendingIntent, Int> {
129
- val pauseResumeIntent =
130
- if (state.paused) {
131
- state.resumeIntent
132
- } else {
133
- state.pauseIntent
134
- }
176
+ private fun createAction(
177
+ context: Context,
178
+ action: String,
179
+ requestCode: Int,
180
+ title: String,
181
+ ): NotificationCompat.Action = NotificationCompat.Action(null, title, createBroadcast(context, Intent(action), requestCode))
135
182
 
136
- val pauseResumePendingIntent =
137
- PendingIntent.getBroadcast(
138
- context,
139
- 0,
140
- pauseResumeIntent!!,
141
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
142
- )
183
+ private fun createBroadcast(
184
+ context: Context,
185
+ intent: Intent,
186
+ requestCode: Int,
187
+ ): PendingIntent =
188
+ PendingIntent.getBroadcast(
189
+ context,
190
+ requestCode,
191
+ intent.apply { `package` = context.packageName },
192
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
193
+ )
194
+
195
+ // The system chronometer always ticks against wall time, so the recording's paused
196
+ // spans are carved out by shifting the base (`startedAtMs`) forward on each resume.
197
+ private fun setupChronometer(builder: NotificationCompat.Builder) {
198
+ val now = System.currentTimeMillis()
143
199
 
144
- val pauseId =
145
- if (state.pauseIconResourceName != null) {
146
- context.resources.getIdentifier(state.pauseIconResourceName, "drawable", context.packageName)
147
- } else {
148
- android.R.drawable.ic_media_pause
200
+ if (state.usesChronometer && !state.paused) {
201
+ if (state.startedAtMs == null) {
202
+ state.startedAtMs = now
149
203
  }
150
- val resumeId =
151
- if (state.resumeIconResourceName != null) {
152
- context.resources.getIdentifier(state.resumeIconResourceName, "drawable", context.packageName)
153
- } else {
154
- android.R.drawable.ic_media_play
204
+ state.pausedAtMs?.let { pausedAt ->
205
+ state.startedAtMs = state.startedAtMs!! + (now - pausedAt)
206
+ state.pausedAtMs = null
155
207
  }
156
-
157
- val iconId = if (state.paused) resumeId else pauseId
158
- return pauseResumePendingIntent to iconId
208
+ builder
209
+ .setWhen(state.startedAtMs!!)
210
+ .setShowWhen(true)
211
+ .setUsesChronometer(true)
212
+ } else {
213
+ if (state.usesChronometer && state.paused && state.pausedAtMs == null) {
214
+ state.pausedAtMs = now
215
+ }
216
+ builder
217
+ .setUsesChronometer(false)
218
+ .setShowWhen(false)
219
+ }
159
220
  }
160
221
 
161
- private fun setupRemoteView(
162
- views: List<RemoteViews>,
163
- pauseResumePendingIntent: PendingIntent,
164
- iconId: Int,
165
- ) {
166
- val iconColor =
167
- if (state.darkTheme) {
168
- Color.WHITE // Dark Mode -> White Icon
169
- } else {
170
- Color.BLACK // Light Mode -> Black Icon
171
- }
172
- for (view in views) {
173
- view.setTextViewText(R.id.notification_title, state.title)
174
- view.setTextViewText(R.id.notification_content, state.contentText)
175
- view.setImageViewResource(R.id.notification_action_btn, iconId)
176
- view.setInt(R.id.notification_action_btn, "setColorFilter", iconColor)
177
- view.setOnClickPendingIntent(R.id.notification_action_btn, pauseResumePendingIntent)
222
+ private fun resolveDrawable(
223
+ context: Context,
224
+ resourceName: String?,
225
+ ): Int? {
226
+ if (resourceName == null) {
227
+ return null
178
228
  }
229
+ val resourceId = context.resources.getIdentifier(resourceName, "drawable", context.packageName)
230
+ return if (resourceId != 0) resourceId else null
179
231
  }
180
232
 
181
- // not used currently, left for future reference
182
- // private fun loadBitmapFromUri(
183
- // context: Context,
184
- // uriString: String?,
185
- // ): Bitmap? =
186
- // try {
187
- // val uri = android.net.Uri.parse(uriString)
188
- // val inputStream: InputStream
189
- // if (uri.scheme == "http" || uri.scheme == "https") {
190
- // // web URL
191
- // val connection = java.net.URL(uriString).openConnection()
192
- // connection.doInput = true
193
- // connection.connect()
194
- // inputStream = connection.inputStream
195
- // } else {
196
- // // local files
197
- // inputStream = context.contentResolver.openInputStream(uri)!!
198
- // }
199
- // android.graphics.BitmapFactory.decodeStream(inputStream)
200
- // } catch (e: Exception) {
201
- // Log.e(TAG, "Failed to load bitmap from URI: $uriString", e)
202
- // null
203
- // }
233
+ private fun parseMapFromRN(options: ReadableMap?) {
234
+ state.title = options.stringOr("title", state.title ?: "Recording Audio")
235
+ state.contentText = options.stringOr("contentText", state.contentText ?: "Audio recording is in progress/paused")
236
+ state.smallIconResourceName = options.stringOr("smallIconResourceName", state.smallIconResourceName)
237
+ state.largeIconResourceName = options.stringOr("largeIconResourceName", state.largeIconResourceName)
238
+ state.backgroundColor = options.intOr("color", state.backgroundColor)
239
+ state.showStopAction = options.boolOr("showStopAction", state.showStopAction)
240
+ state.dismissible = options.boolOr("dismissible", state.dismissible)
241
+ state.pauseActionTitle = options.stringOr("pauseActionTitle", state.pauseActionTitle)
242
+ state.resumeActionTitle = options.stringOr("resumeActionTitle", state.resumeActionTitle)
243
+ state.stopActionTitle = options.stringOr("stopActionTitle", state.stopActionTitle)
244
+ state.deepLinkUri = options.stringOr("deepLinkUri", state.deepLinkUri)
245
+ state.usesChronometer = options.boolOr("usesChronometer", state.usesChronometer)
246
+ state.paused = options.boolOr("paused", state.paused)
247
+ }
204
248
 
205
- private fun getBuilder(): NotificationCompat.Builder {
206
- val context = reactContext.get() ?: throw IllegalStateException("React context is null")
207
- if (state.builder == null) {
208
- val openAppIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
209
- val pendingIntent = PendingIntent.getActivity(context, 0, openAppIntent, PendingIntent.FLAG_IMMUTABLE)
249
+ private fun ReadableMap?.stringOr(
250
+ key: String,
251
+ fallback: String?,
252
+ ): String? = if (this?.hasKey(key) == true) getString(key) else fallback
210
253
 
211
- state.builder =
212
- NotificationCompat
213
- .Builder(context, channelId)
214
- .setOngoing(true)
215
- .setContentIntent(pendingIntent)
216
- }
217
- if (state.smallIconResourceName == null) {
218
- state.builder!!.setSmallIcon(android.R.drawable.ic_btn_speak_now)
219
- }
220
- return state.builder!!
221
- }
254
+ private fun ReadableMap?.boolOr(
255
+ key: String,
256
+ fallback: Boolean,
257
+ ): Boolean = if (this?.hasKey(key) == true) getBoolean(key) else fallback
258
+
259
+ private fun ReadableMap?.intOr(
260
+ key: String,
261
+ fallback: Int?,
262
+ ): Int? = if (this?.hasKey(key) == true) getInt(key) else fallback
222
263
 
223
264
  private fun createNotificationChannel(context: ReactApplicationContext) {
224
265
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -238,84 +279,37 @@ class RecordingNotification(
238
279
  Log.d(TAG, "Notification channel created: $channelId")
239
280
  }
240
281
 
241
- private fun parseMapFromRN(options: ReadableMap?) {
242
- state.title = if (options?.hasKey("title") == true) options.getString("title") else state.title ?: "Recording Audio"
243
- state.contentText =
244
- if (options?.hasKey("contentText") == true) {
245
- options.getString("contentText")
246
- } else {
247
- state.contentText ?: "Audio recording is in progress/paused"
248
- }
249
- state.smallIconResourceName =
250
- if (options?.hasKey("smallIconResourceName") ==
251
- true
252
- ) {
253
- options.getString("smallIconResourceName")
254
- } else {
255
- state.smallIconResourceName ?: null
256
- }
257
- state.largeIconResourceName =
258
- if (options?.hasKey("largeIconResourceName") ==
259
- true
260
- ) {
261
- options.getString("largeIconResourceName")
262
- } else {
263
- state.largeIconResourceName ?: null
264
- }
265
- state.pauseIconResourceName =
266
- if (options?.hasKey("pauseIconResourceName") ==
267
- true
268
- ) {
269
- options.getString("pauseIconResourceName")
270
- } else {
271
- state.pauseIconResourceName ?: null
272
- }
273
- state.resumeIconResourceName =
274
- if (options?.hasKey("resumeIconResourceName") ==
275
- true
276
- ) {
277
- options.getString("resumeIconResourceName")
278
- } else {
279
- state.resumeIconResourceName ?: null
280
- }
281
- state.backgroundColor = if (options?.hasKey("color") == true) options.getInt("color") else state.backgroundColor ?: null
282
- state.paused = if (options?.hasKey("paused") == true) options.getBoolean("paused") else false
283
- }
284
-
285
282
  override fun hide() {
286
- val context = reactContext.get() ?: throw IllegalStateException("React context is null")
287
- if (state.receiver != null) {
288
- context.unregisterReceiver(state.receiver)
289
- context.unregisterComponentCallbacks(this)
290
- state.receiver = null
283
+ try {
284
+ val context = reactContext.get() ?: throw IllegalStateException("React context is null")
285
+ state.receiver?.let {
286
+ context.unregisterReceiver(it)
287
+ state.receiver = null
288
+ }
289
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
290
+ notificationManager.cancel(notificationId)
291
+ } finally {
292
+ state.initialized = false
293
+ state.cachedRNOptions = null
294
+ state.title = null
295
+ state.contentText = null
296
+ state.smallIconResourceName = null
297
+ state.largeIconResourceName = null
298
+ state.backgroundColor = null
299
+ state.showStopAction = false
300
+ state.dismissible = false
301
+ state.pauseActionTitle = null
302
+ state.resumeActionTitle = null
303
+ state.stopActionTitle = null
304
+ state.deepLinkUri = null
305
+ state.usesChronometer = false
306
+ state.startedAtMs = null
307
+ state.pausedAtMs = null
308
+ state.paused = false
291
309
  }
292
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
293
- notificationManager.cancel(notificationId)
294
- state.initialized = false
295
- state.builder = null
296
310
  }
297
311
 
298
312
  override fun getNotificationId(): Int = notificationId
299
313
 
300
314
  override fun getChannelId(): String = channelId
301
-
302
- @RequiresApi(Build.VERSION_CODES.O)
303
- override fun onConfigurationChanged(newConfig: Configuration) {
304
- val currentNightMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
305
- if (currentNightMode != state.darkTheme) {
306
- // Theme changed, rebuild notification
307
- state.darkTheme = currentNightMode
308
- val notification = show(state.cachedRNOptions)
309
- val context = reactContext.get()
310
- if (context != null) {
311
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
312
- notificationManager.notify(notificationId, notification)
313
- }
314
- }
315
- }
316
-
317
- @Deprecated("Deprecated in Java")
318
- override fun onLowMemory() {
319
- // left to listen for ui mode changes
320
- }
321
315
  }