Skip to content

Commit c1ef063

Browse files
committed
Persistence
1 parent c5d560d commit c1ef063

File tree

4 files changed

+574
-12
lines changed

4 files changed

+574
-12
lines changed

android/src/main/java/com/dooboolab.audiorecorderplayer/RNAudioRecorderPlayerModule.kt

Lines changed: 228 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.dooboolab.audiorecorderplayer
22

33
import android.Manifest
4+
import android.content.Context
5+
import android.content.SharedPreferences
46
import android.content.pm.PackageManager
57
import android.media.MediaPlayer
68
import android.media.MediaRecorder
@@ -12,13 +14,15 @@ import android.os.SystemClock
1214
import android.util.Log
1315
import androidx.core.app.ActivityCompat
1416
import com.facebook.react.bridge.*
17+
import com.facebook.react.bridge.LifecycleEventListener
1518
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
1619
import com.facebook.react.modules.core.PermissionListener
20+
import java.io.File
1721
import java.io.IOException
1822
import java.util.*
1923
import kotlin.math.log10
2024

21-
class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), PermissionListener {
25+
class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), PermissionListener, LifecycleEventListener {
2226
private var audioFileURL = ""
2327
private var subsDurationMillis = 500
2428
private var _meteringEnabled = false
@@ -29,7 +33,63 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
2933
private var mTimer: Timer? = null
3034
private var pausedRecordTime = 0L
3135
private var totalPausedRecordTime = 0L
36+
private var recordStartTime = 0L
37+
private var accumulatedRecordTime = 0L
38+
private var isRecordingActive = false
39+
private val audioSegmentURLs = mutableListOf<String>()
3240
var recordHandler: Handler? = Handler(Looper.getMainLooper())
41+
42+
// SharedPreferences keys for session recovery
43+
private val PREFS_NAME = "RNAudioRecorderPrefs"
44+
private val KEY_SEGMENT_URLS = "segmentUrls"
45+
private val KEY_ACCUMULATED_TIME = "accumulatedTime"
46+
private val KEY_IS_RECORDING = "isRecording"
47+
private val KEY_IS_PAUSED = "isPaused"
48+
private val KEY_START_TIME = "startTime"
49+
50+
private fun getSharedPreferences(): SharedPreferences {
51+
return reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
52+
}
53+
54+
init {
55+
// Register lifecycle listener to handle app termination
56+
reactContext.addLifecycleEventListener(this)
57+
}
58+
59+
private fun saveRecoveryState() {
60+
val prefs = getSharedPreferences()
61+
val editor = prefs.edit()
62+
63+
// Save segment URLs as comma-separated string
64+
editor.putString(KEY_SEGMENT_URLS, audioSegmentURLs.joinToString(","))
65+
66+
// Save timing information
67+
editor.putLong(KEY_ACCUMULATED_TIME, accumulatedRecordTime)
68+
69+
// Save recording state
70+
editor.putBoolean(KEY_IS_RECORDING, mediaRecorder != null)
71+
editor.putBoolean(KEY_IS_PAUSED, !isRecordingActive && mediaRecorder != null)
72+
73+
// Save start time if recording is active
74+
if (isRecordingActive && recordStartTime > 0) {
75+
editor.putLong(KEY_START_TIME, recordStartTime)
76+
} else {
77+
editor.remove(KEY_START_TIME)
78+
}
79+
80+
editor.commit() // Use commit() instead of apply() for immediate saving
81+
}
82+
83+
private fun clearRecoveryState() {
84+
val prefs = getSharedPreferences()
85+
val editor = prefs.edit()
86+
editor.remove(KEY_SEGMENT_URLS)
87+
editor.remove(KEY_ACCUMULATED_TIME)
88+
editor.remove(KEY_IS_RECORDING)
89+
editor.remove(KEY_IS_PAUSED)
90+
editor.remove(KEY_START_TIME)
91+
editor.apply()
92+
}
3393
override fun getName(): String {
3494
return tag
3595
}
@@ -92,6 +152,12 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
92152
MediaRecorder()
93153
}
94154

155+
// Clear any previous recovery state when starting new recording
156+
audioSegmentURLs.clear()
157+
audioSegmentURLs.add(audioFileURL)
158+
accumulatedRecordTime = 0L
159+
clearRecoveryState()
160+
95161
try {
96162
if (audioSet == null) {
97163
newMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
@@ -122,6 +188,8 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
122188

123189
mediaRecorder = newMediaRecorder
124190

191+
recordStartTime = SystemClock.elapsedRealtime()
192+
isRecordingActive = true
125193
val systemTime = SystemClock.elapsedRealtime()
126194
recorderRunnable = object : Runnable {
127195
override fun run() {
@@ -165,6 +233,8 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
165233
try {
166234
mediaRecorder!!.resume()
167235
totalPausedRecordTime += SystemClock.elapsedRealtime() - pausedRecordTime;
236+
isRecordingActive = true
237+
recordStartTime = SystemClock.elapsedRealtime()
168238
recorderRunnable?.let { recordHandler!!.postDelayed(it, subsDurationMillis.toLong()) }
169239
promise.resolve("Recorder resumed.")
170240
} catch (e: Exception) {
@@ -183,6 +253,14 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
183253
try {
184254
mediaRecorder!!.pause()
185255
pausedRecordTime = SystemClock.elapsedRealtime();
256+
257+
// Update timing before pausing
258+
if (isRecordingActive && recordStartTime > 0) {
259+
accumulatedRecordTime += SystemClock.elapsedRealtime() - recordStartTime
260+
recordStartTime = 0
261+
isRecordingActive = false
262+
}
263+
186264
recorderRunnable?.let { recordHandler!!.removeCallbacks(it) };
187265
promise.resolve("Recorder paused.")
188266
} catch (e: Exception) {
@@ -214,6 +292,13 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
214292
}
215293

216294
try {
295+
// Update timing one final time before stopping
296+
if (isRecordingActive && recordStartTime > 0) {
297+
accumulatedRecordTime += SystemClock.elapsedRealtime() - recordStartTime
298+
recordStartTime = 0
299+
isRecordingActive = false
300+
}
301+
217302
mediaRecorder!!.stop()
218303
mediaRecorder!!.release()
219304
mediaRecorder = null
@@ -232,6 +317,10 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
232317
return
233318
}
234319

320+
// Clear recovery state after successful stop
321+
clearRecoveryState()
322+
audioSegmentURLs.clear()
323+
235324
Log.d(tag, "Recording completed successfully: ${audioFile.length()} bytes")
236325
promise.resolve("file:///$audioFileURL")
237326
} catch (stopException: RuntimeException) {
@@ -448,7 +537,145 @@ class RNAudioRecorderPlayerModule(private val reactContext: ReactApplicationCont
448537
subsDurationMillis = (sec * 1000).toInt()
449538
promise.resolve("setSubscriptionDuration: $subsDurationMillis")
450539
}
540+
541+
@ReactMethod
542+
fun hasRecoverableSession(promise: Promise) {
543+
val prefs = getSharedPreferences()
544+
val segmentUrlsString = prefs.getString(KEY_SEGMENT_URLS, "")
545+
546+
if (!segmentUrlsString.isNullOrEmpty()) {
547+
val segmentUrls = segmentUrlsString.split(",").filter { it.isNotEmpty() }
548+
549+
// Verify at least one segment file still exists
550+
val validSegments = segmentUrls.filter { path ->
551+
File(path).exists()
552+
}
553+
554+
if (validSegments.isNotEmpty()) {
555+
val result = Arguments.createMap()
556+
result.putBoolean("hasSession", true)
557+
result.putInt("segmentCount", validSegments.size)
558+
result.putDouble("accumulatedTime", prefs.getLong(KEY_ACCUMULATED_TIME, 0L).toDouble())
559+
result.putBoolean("wasRecording", prefs.getBoolean(KEY_IS_RECORDING, false))
560+
result.putBoolean("wasPaused", prefs.getBoolean(KEY_IS_PAUSED, false))
561+
promise.resolve(result)
562+
} else {
563+
// No valid segments found, clear recovery state
564+
clearRecoveryState()
565+
val result = Arguments.createMap()
566+
result.putBoolean("hasSession", false)
567+
promise.resolve(result)
568+
}
569+
} else {
570+
val result = Arguments.createMap()
571+
result.putBoolean("hasSession", false)
572+
promise.resolve(result)
573+
}
574+
}
575+
576+
@ReactMethod
577+
fun recoverSession(promise: Promise) {
578+
val prefs = getSharedPreferences()
579+
val segmentUrlsString = prefs.getString(KEY_SEGMENT_URLS, "")
580+
581+
if (segmentUrlsString.isNullOrEmpty()) {
582+
promise.reject("NO_SESSION", "No recoverable session found")
583+
return
584+
}
585+
586+
val segmentUrls = segmentUrlsString.split(",").filter { it.isNotEmpty() }
587+
588+
// Verify files exist
589+
val validSegments = segmentUrls.filter { path ->
590+
File(path).exists()
591+
}
592+
593+
if (validSegments.isEmpty()) {
594+
clearRecoveryState()
595+
promise.reject("NO_VALID_SEGMENTS", "No valid audio segments found")
596+
return
597+
}
598+
599+
// Restore segment URLs
600+
audioSegmentURLs.clear()
601+
audioSegmentURLs.addAll(validSegments)
602+
603+
// Restore timing information
604+
accumulatedRecordTime = prefs.getLong(KEY_ACCUMULATED_TIME, 0L)
605+
606+
// Restore recording state flags
607+
val wasRecording = prefs.getBoolean(KEY_IS_RECORDING, false)
608+
val wasPaused = prefs.getBoolean(KEY_IS_PAUSED, false)
609+
610+
// Return comma-separated segment paths and state info
611+
val result = Arguments.createMap()
612+
result.putString("segments", validSegments.map { "file:///$it" }.joinToString(","))
613+
result.putDouble("accumulatedTime", accumulatedRecordTime.toDouble())
614+
result.putBoolean("wasRecording", wasRecording)
615+
result.putBoolean("wasPaused", wasPaused)
616+
617+
// Clear the recovery state after successful recovery
618+
clearRecoveryState()
619+
620+
promise.resolve(result)
621+
}
622+
623+
@ReactMethod
624+
fun clearRecoverySession(promise: Promise) {
625+
clearRecoveryState()
626+
promise.resolve("Recovery session cleared")
627+
}
451628

629+
// Lifecycle event handlers for saving state on termination
630+
override fun onHostResume() {
631+
// App resumed from background
632+
// We don't need to do anything here since recording continues in background
633+
}
634+
635+
override fun onHostPause() {
636+
// App is going to background or being terminated
637+
// Save state quickly before potential termination
638+
if (mediaRecorder != null) {
639+
// Update timing if recording
640+
if (isRecordingActive && recordStartTime > 0) {
641+
val currentTime = SystemClock.elapsedRealtime()
642+
accumulatedRecordTime += currentTime - recordStartTime
643+
// Don't reset recordStartTime here in case it's just backgrounding
644+
}
645+
646+
// Add current audio file to segments if not already added
647+
if (audioFileURL.isNotEmpty() && !audioSegmentURLs.contains(audioFileURL)) {
648+
audioSegmentURLs.add(audioFileURL)
649+
}
650+
651+
saveRecoveryState()
652+
}
653+
}
654+
655+
override fun onHostDestroy() {
656+
// App is being destroyed
657+
// Final save before termination
658+
if (mediaRecorder != null) {
659+
// Final timing update
660+
if (isRecordingActive && recordStartTime > 0) {
661+
val currentTime = SystemClock.elapsedRealtime()
662+
accumulatedRecordTime += currentTime - recordStartTime
663+
recordStartTime = 0
664+
isRecordingActive = false
665+
}
666+
667+
// Ensure current segment is saved
668+
if (audioFileURL.isNotEmpty() && !audioSegmentURLs.contains(audioFileURL)) {
669+
audioSegmentURLs.add(audioFileURL)
670+
}
671+
672+
saveRecoveryState()
673+
}
674+
675+
// Unregister lifecycle listener
676+
reactContext.removeLifecycleEventListener(this)
677+
}
678+
452679
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray): Boolean {
453680
var requestRecordAudioPermission: Int = 200
454681

index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,21 @@ export type PlayBackType = {
156156
isFinished: boolean;
157157
};
158158

159+
export type RecoverySessionInfo = {
160+
hasSession: boolean;
161+
segmentCount?: number;
162+
accumulatedTime?: number;
163+
wasRecording?: boolean;
164+
wasPaused?: boolean;
165+
};
166+
167+
export type RecoveredSessionData = {
168+
segments: string;
169+
accumulatedTime: number;
170+
wasRecording: boolean;
171+
wasPaused: boolean;
172+
};
173+
159174
class AudioRecorderPlayer {
160175
private _isRecording: boolean = false;
161176
private _isPlaying: boolean = false;
@@ -455,6 +470,30 @@ class AudioRecorderPlayer {
455470
setSubscriptionDuration = async (sec: number): Promise<string> => {
456471
return RNAudioRecorderPlayer.setSubscriptionDuration(sec);
457472
};
473+
474+
/**
475+
* Check if there's a recoverable recording session from a previous app termination.
476+
* @returns {Promise<RecoverySessionInfo>}
477+
*/
478+
hasRecoverableSession = async (): Promise<RecoverySessionInfo> => {
479+
return RNAudioRecorderPlayer.hasRecoverableSession();
480+
};
481+
482+
/**
483+
* Recover a recording session from a previous app termination.
484+
* @returns {Promise<RecoveredSessionData>}
485+
*/
486+
recoverSession = async (): Promise<RecoveredSessionData> => {
487+
return RNAudioRecorderPlayer.recoverSession();
488+
};
489+
490+
/**
491+
* Clear any saved recovery session data.
492+
* @returns {Promise<string>}
493+
*/
494+
clearRecoverySession = async (): Promise<string> => {
495+
return RNAudioRecorderPlayer.clearRecoverySession();
496+
};
458497
}
459498

460499
export default AudioRecorderPlayer;

ios/RNAudioRecorderPlayer.m

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,13 @@ @interface RCT_EXTERN_MODULE(RNAudioRecorderPlayer, RCTEventEmitter)
5656
RCT_EXTERN_METHOD(stopPlayer:(RCTPromiseResolveBlock)resolve
5757
rejecter:(RCTPromiseRejectBlock)reject);
5858

59+
RCT_EXTERN_METHOD(hasRecoverableSession:(RCTPromiseResolveBlock)resolve
60+
rejecter:(RCTPromiseRejectBlock)reject);
61+
62+
RCT_EXTERN_METHOD(recoverSession:(RCTPromiseResolveBlock)resolve
63+
rejecter:(RCTPromiseRejectBlock)reject);
64+
65+
RCT_EXTERN_METHOD(clearRecoverySession:(RCTPromiseResolveBlock)resolve
66+
rejecter:(RCTPromiseRejectBlock)reject);
67+
5968
@end

0 commit comments

Comments
 (0)