Skip to content

Conversation

@hongdtt-ominext
Copy link
Contributor

@hongdtt-ominext hongdtt-ominext commented Jan 8, 2026

feat: test record when incoming call
fix: add recording foreground service and fix call interruption
fix: stop record when kill app or crash app
fix: record in background for android

Summary by CodeRabbit

  • New Features

    • Added background audio recording support for Android
    • Introduced audio focus management for better multi-app audio compatibility
  • Performance

    • Optimized memory usage during recording and playback on iOS
    • Enhanced battery efficiency with improved session management
  • Bug Fixes

    • Improved app lifecycle handling and resource cleanup
    • Fixed potential memory leaks from observer management

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 8, 2026

📝 Walkthrough

Walkthrough

This PR introduces a new Android foreground service for background audio recording, refactors Sound.kt to delegate recording lifecycle to the service with audio focus management, and enhances iOS lifecycle handling with interruption observers, memory optimization, and autoreleasepool usage. Minor TypeScript interface formatting adjustments and package metadata update included.

Changes

Cohort / File(s) Summary
Android Foreground Service Foundation
android/src/main/AndroidManifest.xml, android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt
New foreground service declaration with microphone permissions and wake-lock support. Implements 460+ lines of lifecycle management, MediaRecorder control, periodic metering updates via Timer, persistent notification with pending intent, and public API for start/pause/resume/stop recording. Includes safe cleanup on app kill and companion object utilities.
Android Recording Delegation
android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt
Refactored from in-process MediaRecorder to RecordingForegroundService binding via ServiceConnection. Added audio focus handling with OnAudioFocusChangeListener (requests/releases on recording lifecycle, pauses on focus loss). Removed inline metering logic; shifted to service-based callbacks. Integrated error handling and cleanup paths for service unavailability.
iOS Lifecycle & Memory Improvements
ios/Sound.swift
Increased metering subscription duration (0.06→0.1s). Added AVAudioSession interruption observer setup/cleanup. Introduced autoreleasepool in timer callbacks for memory pressure reduction. Added handleAppWillTerminate/finalizeRecordingOnKill for graceful shutdown. Enhanced playback to download to temporary file first. Expanded listener/observer removal with DEBUG logging. Improved deinit cleanup thoroughness.
Configuration & Type Formatting
package.json, src/specs/Sound.nitro.ts
Set "private": true in package.json. Reformatted AudioSet and Sound interface declarations to single-line syntax without semantic changes.

Sequence Diagram

sequenceDiagram
    participant SoundActivity as Sound (Activity)
    participant AudioMgr as AudioManager
    participant RecordingFG as RecordingForegroundService
    participant MediaRec as MediaRecorder
    participant NotifMgr as Notification System
    
    SoundActivity->>AudioMgr: requestAudioFocus()
    AudioMgr-->>SoundActivity: focus granted
    SoundActivity->>SoundActivity: bindService()
    SoundActivity->>RecordingFG: onServiceConnected()
    RecordingFG->>RecordingFG: onCreate()
    RecordingFG->>NotifMgr: createNotificationChannel()
    SoundActivity->>RecordingFG: startRecording(filePath, config, enableMetering)
    RecordingFG->>MediaRec: setAudioSource(), setOutputFormat(), setAudioEncoder()
    RecordingFG->>MediaRec: prepare() + start()
    RecordingFG->>RecordingFG: startTimer() for metering updates
    RecordingFG->>NotifMgr: startForeground(notification)
    RecordingFG-->>SoundActivity: recording started
    RecordingFG->>RecordingFG: Timer: query maxAmplitude
    RecordingFG-->>SoundActivity: recordBackListener(metering callback)
    alt Pause Recording
        SoundActivity->>RecordingFG: pauseRecording()
        RecordingFG->>MediaRec: pause()
    else Resume Recording
        SoundActivity->>RecordingFG: resumeRecording()
        RecordingFG->>MediaRec: resume()
    else Stop Recording
        SoundActivity->>RecordingFG: stopRecording()
        RecordingFG->>MediaRec: stop() + release()
        RecordingFG->>RecordingFG: stopTimer()
        RecordingFG->>NotifMgr: stopForeground()
        RecordingFG-->>SoundActivity: return filePath
        SoundActivity->>AudioMgr: abandonAudioFocus()
        SoundActivity->>SoundActivity: unbindService()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • PR #720: Both modify ios/Sound.swift to address timer/threading for playback and record timers, scheduling callbacks on the main thread for correct callback execution.

Suggested labels

🤖 android, 🛠 bugfix

Suggested reviewers

  • hyochan

Poem

🐰 A foreground service hops into view,
Audio focus balanced, lifecycle anew,
Autoreleasepool sprouts on the iOS side,
Metering flows from the service's tide,
Wake-locks held while recordings glide! 🎙️

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix record audio in background' directly describes the main objective of the changeset, which implements foreground service support for background audio recording on Android.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @hongdtt-ominext, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the audio recording and playback capabilities across both Android and iOS platforms. The primary focus is on improving the reliability of background recording, handling audio interruptions gracefully, and optimizing memory usage during playback, leading to a more stable and efficient audio experience.

Highlights

  • Android Background Recording: Implemented a new RecordingForegroundService to enable robust audio recording in the background on Android, ensuring recordings continue even when the app is minimized or the screen is off. This service manages the MediaRecorder lifecycle and utilizes a wake lock.
  • Android Interruption and App Termination Handling: Added audio focus management to pause recordings during incoming calls or other audio interruptions. The foreground service also includes logic to gracefully stop and finalize recordings if the app is killed or crashes, preventing corrupted audio files.
  • iOS Interruption and App Termination Handling: Introduced observers for AVAudioSession interruptions and app termination (UIApplication.willTerminateNotification) to ensure that iOS recordings are properly stopped and finalized when calls come in or the app is closed.
  • iOS Playback Memory Optimization: Modified HTTP audio playback on iOS to download audio to a temporary file and play from there, rather than loading the entire file into memory. This significantly reduces memory consumption for large audio files.
  • Code Refactoring and Cleanup: Refactored the Android HybridSound class to delegate recording responsibilities to the new foreground service, simplifying its logic. Enhanced deinit methods in iOS for more thorough resource cleanup.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ios/Sound.swift (1)

848-880: Temp file deletion timing is risky; player may still need the file.

The temp file is deleted after only 1 second (line 873-875), but AVAudioPlayer(contentsOf:) may still be buffering or reading from the file during playback. This could cause playback failures for longer audio files or on slower devices.

🐛 Suggested fix: delete file after playback completes

Store the temp file URL and delete it in the player delegate's audioPlayerDidFinishPlaying or when stopPlayer is called:

+    private var tempAudioFileURL: URL?
+
     private func setupEnginePlayer(url: String, httpHeaders: Dictionary<String, String>?, promise: Promise<String>) {
         // ...
         do {
             // Use streaming download instead of loading into memory
             let data = try Data(contentsOf: audioURL)
             try data.write(to: tempFile)
+            self.tempAudioFileURL = tempFile
             
             // Create player from file instead of data (more memory efficient)
             self.audioPlayer = try AVAudioPlayer(contentsOf: tempFile)
             // ...
             promise.resolve(withResult: url)
-            
-            // Clean up temp file after a delay (player keeps reference)
-            DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0) {
-                try? FileManager.default.removeItem(at: tempFile)
-            }
         } catch {

Then in stopPlayer() and audioPlayerDidFinishPlaying:

if let tempURL = self.tempAudioFileURL {
    try? FileManager.default.removeItem(at: tempURL)
    self.tempAudioFileURL = nil
}
🤖 Fix all issues with AI agents
In
@android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt:
- Around line 66-68: The singleton backing field instance in
RecordingForegroundService is not volatile, so updates from onCreate/onDestroy
may not be visible to other threads; annotate the private var instance:
RecordingForegroundService? with @Volatile to ensure proper visibility across
threads and keep the existing getInstance() accessor unchanged (refer to the
RecordingForegroundService class, the instance property and getInstance()
method).
- Around line 252-271: resumeRecording currently calls startRecordTimer(60L)
which hardcodes the update interval; replace the literal with the stored
subscriptionDurationMs used in startRecording so the resumed timer respects the
caller's requested interval. In the resumeRecording method (and where
startRecordTimer is invoked), use subscriptionDurationMs instead of 60L,
ensuring subscriptionDurationMs is non-null/initialized (fall back to a sensible
default only if necessary) and leave recordStartTime, pausedRecordTime, isPaused
and isRecording updates intact.

In @android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt:
- Around line 209-213: The Thread.sleep(300) call in Sound.kt is fragile;
replace the sleep-based wait with a ServiceConnection-based flow: remove the
Thread.sleep(300) and stop relying on RecordingForegroundService.getInstance()
immediately; instead, store the recording inputs in a pendingRecordingParams
data holder and the promise in pendingRecordPromise, bind to the service and
start the actual recording from onServiceConnected by calling a helper like
startRecordingWithService(pendingRecordingParams, pendingRecordPromise), then
clear the pending values and set isServiceBound/recordingService from the binder
(RecordingForegroundService.RecordingBinder) so recording only begins when the
service is actually connected.

In @ios/Sound.swift:
- Line 942: The closure passed to Timer.scheduledTimer is capturing an unused
parameter named `timer` (in the `playTimer =
Timer.scheduledTimer(withTimeInterval:subscriptionDuration,repeats:true)` call);
change the closure signature to use an unused wildcard parameter (i.e. replace
`timer in` with `_ in`) so the unused parameter warning is resolved while
preserving the existing `[weak self]` capture and body.

In @package.json:
- Line 4: The package.json now has "private": true which prevents npm publishing
but conflicts with the existing release-it config that sets "publish": true;
decide the intended behavior and make them consistent: if the package should be
published, remove or set "private" to false so release-it can publish; if the
package is intentionally private, update the release-it configuration (the
"publish": true setting) to false to prevent attempts to publish. Ensure the
change touches the "private" field and the release-it "publish" option to keep
both aligned.

In @src/specs/Sound.nitro.ts:
- Around line 115-116: The export interface block for AudioSet (and the similar
block at lines 140-143) is misformatted and violates the project's
Prettier/ESLint 2-space indentation rule; fix by reformatting the declaration so
it uses 2-space indentation and standard line breaks (e.g., place the extends
clause on the next line indented with two spaces and ensure the closing brace
aligns correctly) for the symbols AudioSet, IOSAudioSet, AndroidAudioSet, and
CommonAudioSet so the file matches the project's lint/Prettier configuration.
🧹 Nitpick comments (3)
android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt (1)

279-308: Consider logging swallowed exceptions for debugging.

The try-catch blocks intentionally swallow exceptions during cleanup, which is reasonable. However, adding debug logging would help diagnose issues in production.

♻️ Optional: Add logging for swallowed exceptions
             mediaRecorder?.apply {
                 try {
                     stop()
                 } catch (e: Exception) {
-                    // Ignore stop errors
+                    Logger.d("[ForegroundService] Ignoring stop error: ${e.message}")
                 }
                 try {
                     release()
                 } catch (e: Exception) {
-                    // Ignore release errors
+                    Logger.d("[ForegroundService] Ignoring release error: ${e.message}")
                 }
             }
android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt (2)

50-68: Duplicate onRecordingUpdate callback setup.

The callback is set in both onServiceConnected (line 57) and after the Thread.sleep in startRecorder (line 216). This redundancy is likely a workaround for the fragile service startup approach. Fixing the Thread.sleep issue would also eliminate this duplication.

Also applies to: 214-227


717-721: Consider using AudioFocusRequest for API 26+.

The requestAudioFocus(listener, streamType, durationHint) method is deprecated since API 26. For better compatibility and to silence deprecation warnings, use the AudioFocusRequest.Builder pattern.

♻️ Suggested implementation
private var audioFocusRequest: Any? = null // AudioFocusRequest for API 26+

private fun setupAudioFocus() {
    audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
    audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
        // ... existing listener code
    }
    
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val request = android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
            .setOnAudioFocusChangeListener(audioFocusChangeListener!!)
            .build()
        audioFocusRequest = request
        audioManager?.requestAudioFocus(request)
    } else {
        @Suppress("DEPRECATION")
        audioManager?.requestAudioFocus(
            audioFocusChangeListener,
            AudioManager.STREAM_MUSIC,
            AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
        )
    }
}

private fun releaseAudioFocus() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        (audioFocusRequest as? android.media.AudioFocusRequest)?.let {
            audioManager?.abandonAudioFocusRequest(it)
        }
    } else {
        audioFocusChangeListener?.let { listener ->
            @Suppress("DEPRECATION")
            audioManager?.abandonAudioFocus(listener)
        }
    }
    audioFocusRequest = null
    audioFocusChangeListener = null
    audioManager = null
}
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c34ef81 and 0b82d03.

⛔ Files ignored due to path filters (2)
  • nitrogen/generated/ios/NitroSound-Swift-Cxx-Bridge.cpp is excluded by !**/generated/**
  • nitrogen/generated/ios/NitroSound-Swift-Cxx-Bridge.hpp is excluded by !**/generated/**
📒 Files selected for processing (6)
  • android/src/main/AndroidManifest.xml
  • android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt
  • android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt
  • ios/Sound.swift
  • package.json
  • src/specs/Sound.nitro.ts
🧰 Additional context used
📓 Path-based instructions (2)
src/specs/*.nitro.ts

📄 CodeRabbit inference engine (AGENTS.md)

Define Nitro module surfaces in TypeScript spec files located at src/specs/*.nitro.ts

Files:

  • src/specs/Sound.nitro.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use 2-space indentation, single quotes, and es5 trailing commas in TypeScript code (enforced by Prettier/ESLint)
Use camelCase for variables and functions in TypeScript
Use PascalCase for classes and type names in TypeScript

Files:

  • src/specs/Sound.nitro.ts
🧠 Learnings (2)
📚 Learning: 2025-09-07T05:13:03.063Z
Learnt from: CR
Repo: hyochan/react-native-nitro-sound PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-09-07T05:13:03.063Z
Learning: Modify behavior via Nitro specs (src/specs/*.nitro.ts) and native implementations, not by editing generated bindings

Applied to files:

  • src/specs/Sound.nitro.ts
📚 Learning: 2025-09-07T05:13:03.063Z
Learnt from: CR
Repo: hyochan/react-native-nitro-sound PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-09-07T05:13:03.063Z
Learning: Applies to src/specs/*.nitro.ts : Define Nitro module surfaces in TypeScript spec files located at src/specs/*.nitro.ts

Applied to files:

  • src/specs/Sound.nitro.ts
🧬 Code graph analysis (1)
ios/Sound.swift (7)
android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt (4)
  • startRecorder (78-292)
  • addPlayBackListener (605-607)
  • removePlayBackListener (609-611)
  • stopPlayTimer (682-685)
nitrogen/generated/ios/c++/HybridSoundSpecSwift.hpp (10)
  • uri (99-106)
  • uri (99-99)
  • uri (131-138)
  • uri (131-131)
  • callback (193-198)
  • callback (193-193)
  • callback (205-210)
  • callback (205-205)
  • callback (217-222)
  • callback (217-217)
android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt (2)
  • stopRecordTimer (361-364)
  • stop (79-83)
nitrogen/generated/android/kotlin/com/margelo/nitro/sound/RecordBackType.kt (1)
  • isRecording (17-45)
src/specs/Sound.nitro.ts (2)
  • RecordBackType (118-123)
  • PlayBackType (125-129)
nitrogen/generated/android/kotlin/com/margelo/nitro/sound/PlaybackEndType.kt (1)
  • duration (17-39)
nitrogen/generated/android/kotlin/com/margelo/nitro/sound/PlayBackType.kt (1)
  • isMuted (17-42)
🪛 detekt (1.23.8)
android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt

[warning] 250-250: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 265-265: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 280-280: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 337-337: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 361-361: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 438-438: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 657-657: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt

[warning] 286-286: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 291-291: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 313-313: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 377-377: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 456-456: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🪛 GitHub Check: lint
src/specs/Sound.nitro.ts

[warning] 116-116:
Replace ·AndroidAudioSet, with ⏎····AndroidAudioSet,⏎···


[warning] 143-143:
Insert ··


[warning] 142-142:
Insert ··


[warning] 141-141:
Insert ··


[warning] 140-140:
Insert ⏎·

🪛 SwiftLint (0.57.0)
ios/Sound.swift

[Warning] 942-942: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: ci-ios / ios-dependencies
  • GitHub Check: ci-web / build-web
  • GitHub Check: ios-dependencies
🔇 Additional comments (7)
ios/Sound.swift (3)

1029-1054: Good implementation of interruption handling lifecycle.

The observer setup/teardown pattern is well-implemented with proper cleanup in removeInterruptionObserver() and deinit. The dual observation of both interruptionNotification and willTerminateNotification ensures recordings are properly finalized.


1154-1195: Thorough resource cleanup in deinit.

The cleanup is comprehensive: removing observers, invalidating timers, stopping audio components, clearing listeners (to break retain cycles), and deactivating the session. This is well-implemented.


1138-1144: Swift syntax error: trailing comma before closing parenthesis.

Swift does not allow trailing commas in function/initializer argument lists. This will cause a compilation error.

🐛 Fix the trailing comma
                     listener(RecordBackType(
                         isRecording: false,
                         currentPosition: currentTime,
                         currentMetering: nil,
-                        recordSecs: currentTime,
+                        recordSecs: currentTime
                     ))

Likely an incorrect or invalid review comment.

android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt (2)

434-446: Wake lock has a 10-hour timeout - verify this aligns with expected use cases.

The wake lock timeout of 10 hours (line 441) is quite long. While this prevents indefinite locks, it could drain battery if the service isn't properly stopped. The releaseWakeLock() is called in onDestroy(), which should handle normal cleanup.


117-169: Good implementation of recording finalization on app kill.

The onTaskRemovedfinalizeRecordingOnKill flow ensures the audio file is properly finalized when the user swipes away the app. The file header will be written correctly, making the recording playable.

android/src/main/AndroidManifest.xml (1)

6-20: Manifest correctly declares foreground service permissions and service.

The permissions (FOREGROUND_SERVICE, FOREGROUND_SERVICE_MICROPHONE for API 34+, WAKE_LOCK) align with the RecordingForegroundService implementation. The service is correctly declared with foregroundServiceType="microphone" and exported="false".

android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt (1)

324-374: Good cleanup implementation in stopRecorder.

The method properly unbinds the service, stops the foreground service, and releases audio focus in both success and error paths. This ensures resources are always cleaned up.

Comment on lines +66 to +68
private var instance: RecordingForegroundService? = null

fun getInstance(): RecordingForegroundService? = instance
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add @Volatile annotation for thread-safe singleton access.

The instance variable is accessed from multiple threads (main thread in onCreate/onDestroy, IO thread in Sound.kt). Without @Volatile, changes may not be visible across threads.

🔧 Suggested fix
-        private var instance: RecordingForegroundService? = null
+        @Volatile
+        private var instance: RecordingForegroundService? = null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private var instance: RecordingForegroundService? = null
fun getInstance(): RecordingForegroundService? = instance
@Volatile
private var instance: RecordingForegroundService? = null
fun getInstance(): RecordingForegroundService? = instance
🤖 Prompt for AI Agents
In
@android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt
around lines 66 - 68, The singleton backing field instance in
RecordingForegroundService is not volatile, so updates from onCreate/onDestroy
may not be visible to other threads; annotate the private var instance:
RecordingForegroundService? with @Volatile to ensure proper visibility across
threads and keep the existing getInstance() accessor unchanged (refer to the
RecordingForegroundService class, the instance property and getInstance()
method).

Comment on lines +252 to +271
fun resumeRecording(): Boolean {
if (!isPaused) return false

return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mediaRecorder?.resume()
recordStartTime = System.currentTimeMillis() - pausedRecordTime
isPaused = false
isRecording = true
startRecordTimer(60L) // Default, will be updated
updateNotification("Recording in progress...")
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded timer interval in resumeRecording ignores caller's subscription duration.

Line 261 uses a hardcoded 60L instead of the subscriptionDurationMs that was stored during startRecording. This could cause inconsistent update intervals after pause/resume.

🐛 Suggested fix
                 mediaRecorder?.resume()
                 recordStartTime = System.currentTimeMillis() - pausedRecordTime
                 isPaused = false
                 isRecording = true
-                startRecordTimer(60L) // Default, will be updated
+                startRecordTimer(subscriptionDurationMs)
                 updateNotification("Recording in progress...")
                 true
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun resumeRecording(): Boolean {
if (!isPaused) return false
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mediaRecorder?.resume()
recordStartTime = System.currentTimeMillis() - pausedRecordTime
isPaused = false
isRecording = true
startRecordTimer(60L) // Default, will be updated
updateNotification("Recording in progress...")
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
fun resumeRecording(): Boolean {
if (!isPaused) return false
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mediaRecorder?.resume()
recordStartTime = System.currentTimeMillis() - pausedRecordTime
isPaused = false
isRecording = true
startRecordTimer(subscriptionDurationMs)
updateNotification("Recording in progress...")
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
🤖 Prompt for AI Agents
In
@android/src/main/java/com/margelo/nitro/audiorecorderplayer/RecordingForegroundService.kt
around lines 252 - 271, resumeRecording currently calls startRecordTimer(60L)
which hardcodes the update interval; replace the literal with the stored
subscriptionDurationMs used in startRecording so the resumed timer respects the
caller's requested interval. In the resumeRecording method (and where
startRecordTimer is invoked), use subscriptionDurationMs instead of 60L,
ensuring subscriptionDurationMs is non-null/initialized (fall back to a sensible
default only if necessary) and leave recordStartTime, pausedRecordTime, isPaused
and isRecording updates intact.

Comment on lines +209 to +213
// Wait a bit for service to start, then start recording
Thread.sleep(300)

// Get service instance and start recording
val service = RecordingForegroundService.getInstance()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace Thread.sleep(300) with proper service binding callback.

Using Thread.sleep to wait for service startup is fragile and can cause race conditions. The service might not be ready after 300ms on slower devices, or the delay is unnecessarily long on fast devices.

🐛 Suggested approach: Use the ServiceConnection callback

Instead of sleeping, start recording from the onServiceConnected callback. Store the recording parameters and use a pending promise pattern:

private var pendingRecordingParams: RecordingParams? = null
private var pendingRecordPromise: Promise<String>? = null

data class RecordingParams(
    val filePath: String,
    val audioSource: Int,
    val outputFormat: Int,
    val audioEncoder: Int,
    val samplingRate: Int?,
    val channels: Int?,
    val bitrate: Int?,
    val enableMetering: Boolean
)

override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
    val binder = service as RecordingForegroundService.RecordingBinder
    recordingService = binder.getService()
    isServiceBound = true
    
    // Start recording if params are pending
    pendingRecordingParams?.let { params ->
        startRecordingWithService(params, pendingRecordPromise!!)
        pendingRecordingParams = null
        pendingRecordPromise = null
    }
}
🤖 Prompt for AI Agents
In @android/src/main/java/com/margelo/nitro/audiorecorderplayer/Sound.kt around
lines 209 - 213, The Thread.sleep(300) call in Sound.kt is fragile; replace the
sleep-based wait with a ServiceConnection-based flow: remove the
Thread.sleep(300) and stop relying on RecordingForegroundService.getInstance()
immediately; instead, store the recording inputs in a pendingRecordingParams
data holder and the promise in pendingRecordPromise, bind to the service and
start the actual recording from onServiceConnected by calling a helper like
startRecordingWithService(pendingRecordingParams, pendingRecordPromise), then
clear the pending values and set isServiceBound/recordingService from the binder
(RecordingForegroundService.RecordingBinder) so recording only begins when the
service is actually connected.

Comment on lines +696 to +708
val service = RecordingForegroundService.getInstance()
if (service != null && service.isCurrentlyRecording()) {
service.pauseRecording()

recordBackListener?.invoke(
RecordBackType(
isRecording = false,
currentPosition = 0.0,
currentMetering = null,
recordSecs = 0.0
)
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incorrect currentPosition reported on audio focus loss.

When pausing due to audio focus loss, the callback reports currentPosition = 0.0 and recordSecs = 0.0. This should reflect the actual recording position at the time of interruption.

🐛 Suggested fix: Get the actual position from the service
                     handler.post {
                         val service = RecordingForegroundService.getInstance()
                         if (service != null && service.isCurrentlyRecording()) {
+                            val currentTime = System.currentTimeMillis() - service.getRecordStartTime()
                             service.pauseRecording()
                             
                             recordBackListener?.invoke(
                                 RecordBackType(
                                     isRecording = false,
-                                    currentPosition = 0.0,
+                                    currentPosition = currentTime.toDouble(),
                                     currentMetering = null,
-                                    recordSecs = 0.0
+                                    recordSecs = currentTime.toDouble()
                                 )
                             )
                         }
                     }

Note: This requires exposing getRecordStartTime() from RecordingForegroundService.

Committable suggestion skipped: line range outside the PR's diff.


self.didEmitPlaybackEnd = false

self.playTimer = Timer.scheduledTimer(withTimeInterval: self.subscriptionDuration, repeats: true) { [weak self] timer in
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace unused timer parameter with _.

Per SwiftLint warning, the timer parameter is unused.

🔧 Suggested fix
-            self.playTimer = Timer.scheduledTimer(withTimeInterval: self.subscriptionDuration, repeats: true) { [weak self] timer in
+            self.playTimer = Timer.scheduledTimer(withTimeInterval: self.subscriptionDuration, repeats: true) { [weak self] _ in
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.playTimer = Timer.scheduledTimer(withTimeInterval: self.subscriptionDuration, repeats: true) { [weak self] timer in
self.playTimer = Timer.scheduledTimer(withTimeInterval: self.subscriptionDuration, repeats: true) { [weak self] _ in
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 942-942: Unused parameter in a closure should be replaced with _

(unused_closure_parameter)

🤖 Prompt for AI Agents
In @ios/Sound.swift at line 942, The closure passed to Timer.scheduledTimer is
capturing an unused parameter named `timer` (in the `playTimer =
Timer.scheduledTimer(withTimeInterval:subscriptionDuration,repeats:true)` call);
change the closure signature to use an unused wildcard parameter (i.e. replace
`timer in` with `_ in`) so the unused parameter warning is resolved while
preserving the existing `[weak self]` capture and body.

{
"name": "react-native-nitro-sound",
"version": "0.2.10",
"private": true,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Adding "private": true will prevent npm publishing.

This change conflicts with the release-it configuration at line 134 which has "publish": true. If this package is intended to be published to npm (as the existing config suggests), this field will block publishing.

If this was intentional (e.g., converting to a monorepo-only package), please also update the release-it config. Otherwise, this appears to be an accidental addition unrelated to the background recording feature.

🤖 Prompt for AI Agents
In @package.json at line 4, The package.json now has "private": true which
prevents npm publishing but conflicts with the existing release-it config that
sets "publish": true; decide the intended behavior and make them consistent: if
the package should be published, remove or set "private" to false so release-it
can publish; if the package is intentionally private, update the release-it
configuration (the "publish": true setting) to false to prevent attempts to
publish. Ensure the change touches the "private" field and the release-it
"publish" option to keep both aligned.

Comment on lines 115 to +116
export interface AudioSet
extends IOSAudioSet,
AndroidAudioSet,
CommonAudioSet {}
extends IOSAudioSet, AndroidAudioSet, CommonAudioSet {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix formatting to pass lint checks.

The static analysis reports multiple formatting warnings on these lines. The formatting changes introduced here don't match the project's Prettier/ESLint configuration (2-space indentation as per coding guidelines).

🔧 Suggested fix based on lint hints
 export interface AudioSet
-  extends IOSAudioSet, AndroidAudioSet, CommonAudioSet {}
+  extends IOSAudioSet,
+    AndroidAudioSet,
+    CommonAudioSet {}
-export interface Sound extends HybridObject<{
-  ios: 'swift';
-  android: 'kotlin';
-}> {
+export interface Sound
+  extends HybridObject<{
+    ios: 'swift';
+    android: 'kotlin';
+  }> {

Also applies to: 140-143

🧰 Tools
🪛 GitHub Check: lint

[warning] 116-116:
Replace ·AndroidAudioSet, with ⏎····AndroidAudioSet,⏎···

🤖 Prompt for AI Agents
In @src/specs/Sound.nitro.ts around lines 115 - 116, The export interface block
for AudioSet (and the similar block at lines 140-143) is misformatted and
violates the project's Prettier/ESLint 2-space indentation rule; fix by
reformatting the declaration so it uses 2-space indentation and standard line
breaks (e.g., place the extends clause on the next line indented with two spaces
and ensure the closing brace aligns correctly) for the symbols AudioSet,
IOSAudioSet, AndroidAudioSet, and CommonAudioSet so the file matches the
project's lint/Prettier configuration.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly improves background audio recording on Android by introducing a Foreground Service, and adds robust interruption handling for both Android and iOS. The iOS implementation also benefits from memory usage optimizations. While these are great additions, I've identified a few critical and high-severity issues. On Android, there's a race condition when starting the recording service that needs to be addressed. On iOS, there's a potential memory issue with remote audio playback. I've provided detailed comments and suggestions for these points.

val fileUri = Uri.fromFile(File(filePath)).toString()
promise.resolve(fileUri)
// Wait a bit for service to start, then start recording
Thread.sleep(300)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using Thread.sleep() to wait for the service to start is unreliable and can lead to race conditions. The service might take longer than 300ms to initialize, especially on older devices or under heavy load. This could cause RecordingForegroundService.getInstance() to return null, failing the recording.

The recording logic should be triggered from within the onServiceConnected callback to ensure the service is properly bound and available. This would involve storing the Promise and recording parameters as class members and using them inside onServiceConnected.

Comment on lines +231 to +250
fun pauseRecording(): Boolean {
if (!isRecording || isPaused) return false

return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mediaRecorder?.pause()
pausedRecordTime = System.currentTimeMillis() - recordStartTime
isPaused = true
isRecording = false
stopRecordTimer()
updateNotification("Recording paused")
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To fix an issue with interruption handling, pauseRecording should send one final update to listeners with the correct paused position. This ensures the UI has the correct state when recording is paused due to an interruption (like a phone call).

    fun pauseRecording(): Boolean {
        if (!isRecording || isPaused) return false
        
        return try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                mediaRecorder?.pause()
                pausedRecordTime = System.currentTimeMillis() - recordStartTime
                isPaused = true
                isRecording = false
                stopRecordTimer()

                // Notify listener of paused state with final position
                val meteringValue = if (meteringEnabled) lastMeteringValue else METERING_DISABLED_VALUE
                handler.post {
                    onRecordingUpdate?.invoke(isRecording, pausedRecordTime.toDouble(), meteringValue)
                }

                updateNotification("Recording paused")
                true
            } else {
                false
            }
        } catch (e: Exception) {
            Logger.e("[ForegroundService] Error pausing recording: ${e.message}", e)
            false
        }
    }

recordStartTime = System.currentTimeMillis() - pausedRecordTime
isPaused = false
isRecording = true
startRecordTimer(60L) // Default, will be updated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

startRecordTimer is called with a hardcoded value of 60L. This overrides the original subscriptionDuration provided when the recording was started. You should use the stored subscriptionDurationMs to maintain the correct update interval.

                startRecordTimer(subscriptionDurationMs) // Use original subscription duration

Comment on lines +699 to +707

recordBackListener?.invoke(
RecordBackType(
isRecording = false,
currentPosition = 0.0,
currentMetering = null,
recordSecs = 0.0
)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When audio focus is lost, you are invoking recordBackListener with currentPosition = 0.0. This will incorrectly reset the recording progress in the UI. The service should be the single source of truth for the recording state. I've added another comment suggesting a change in RecordingForegroundService.kt to have pauseRecording() emit one final update with the correct position. After applying that change, this recordBackListener invocation should be removed.


do {
// Use streaming download instead of loading into memory
let data = try Data(contentsOf: audioURL)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The comment mentions using a streaming download, but Data(contentsOf: audioURL) loads the entire file's content into memory at once. For large audio files, this can lead to high memory usage and potential app crashes. To achieve true streaming download and playback, you should use URLSession with a URLSessionDownloadTask to download the file to a temporary location and then play it from the file. Alternatively, for real-time streaming, AVPlayer with an AVURLAsset is a better choice than AVAudioPlayer.


return true
} catch (e: Exception) {
e.printStackTrace()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Throughout this file, e.printStackTrace() is used for error handling (e.g., also in pauseRecording, resumeRecording, etc.). It's better to use the provided Logger.e for consistency and to have more control over logging. printStackTrace prints to standard error and bypasses the Android logging system, making logs harder to filter and manage.

Suggested change
e.printStackTrace()
Logger.e("[ForegroundService] Error starting recording: ${e.message}", e)

Comment on lines +283 to +294
mediaRecorder?.apply {
try {
stop()
} catch (e: Exception) {
// Ignore stop errors
}
try {
release()
} catch (e: Exception) {
// Ignore release errors
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Here, exceptions from stop() and release() are silently ignored. In finalizeRecordingOnKill, these exceptions are logged. For consistency and better debugging, consider logging these errors here as well, at least at a warning level.

            mediaRecorder?.apply {
                try {
                    stop()
                } catch (e: Exception) {
                    Logger.w("[ForegroundService] Error stopping MediaRecorder: ${e.message}", e)
                }
                try {
                    release()
                } catch (e: Exception) {
                    Logger.w("[ForegroundService] Error releasing MediaRecorder: ${e.message}", e)
                }
            }

Comment on lines 327 to 371
CoroutineScope(Dispatchers.IO).launch {
try {
mediaRecorder?.apply {
stop()
release()
}
mediaRecorder = null

// Reset metering state
meteringEnabled = false
lastMeteringUpdateTime = 0L
lastMeteringValue = SILENCE_THRESHOLD_DB

val service = RecordingForegroundService.getInstance()
val path = service?.stopRecording() ?: currentRecordingPath

handler.post {
stopRecordTimer()
// Unbind from service
if (isServiceBound) {
try {
context.unbindService(serviceConnection)
} catch (e: Exception) {
// Ignore unbind errors
}
isServiceBound = false
}

// Stop the service
RecordingForegroundService.stop(context)

// Release audio focus
releaseAudioFocus()
}

val path = currentRecordingPath
currentRecordingPath = null // State is cleared regardless of outcome
currentRecordingPath = null

path?.let {
val fileUri = Uri.fromFile(File(it)).toString()
promise.resolve(fileUri)
} ?: promise.reject(Exception("Recorder not started or path is unavailable."))
} catch (e: Exception) {
mediaRecorder?.release()
mediaRecorder = null

// Reset metering state even on error
meteringEnabled = false
lastMeteringUpdateTime = 0L
lastMeteringValue = SILENCE_THRESHOLD_DB

handler.post {
if (isServiceBound) {
try {
context.unbindService(serviceConnection)
} catch (ex: Exception) {
// Ignore
}
isServiceBound = false
}
RecordingForegroundService.stop(context)
releaseAudioFocus()
}
promise.reject(e)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cleanup logic for unbinding the service, stopping it, and releasing audio focus is duplicated in multiple places (here in stopRecorder, and also in startRecorder). This could be extracted into a private helper function to reduce code duplication and improve maintainability.

{
"name": "react-native-nitro-sound",
"version": "0.2.10",
"private": true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The "private": true flag has been added. This will prevent the package from being published to the npm registry. If this library is intended to be published, this line should be removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant