Skip to content

Commit

Permalink
Add support for pausing/resuming the recording
Browse files Browse the repository at this point in the history
This commit implements basic pause/resume functionality via a
notification action in BCR's persistent notification. Pausing is
implemented by keeping the AudioRecord active, but discarding the data
instead of passing it to the encoder. Pausing/resuming does not happen
immediately, but after the processing of one buffer (which should be
under 50ms on most devices). It's fast enough to be imperceptible.

Issue: #198

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed Dec 27, 2022
1 parent a92a66b commit 37dd024
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 12 deletions.
20 changes: 19 additions & 1 deletion app/src/main/java/com/chiller3/bcr/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,12 @@ class Notifications(
* fully static and in progress recording is represented by the presence or absence of the
* notification.
*/
fun createPersistentNotification(@StringRes title: Int, @DrawableRes icon: Int): Notification {
fun createPersistentNotification(
@StringRes title: Int,
@DrawableRes icon: Int,
@StringRes actionText: Int,
actionIntent: Intent,
): Notification {
val notificationIntent = Intent(context, SettingsActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
Expand All @@ -136,6 +141,19 @@ class Notifications(
setContentIntent(pendingIntent)
setOngoing(true)

val actionPendingIntent = PendingIntent.getService(
context,
0,
actionIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)

addAction(Notification.Action.Builder(
null,
context.getString(actionText),
actionPendingIntent,
).build())

// Inhibit 10-second delay when showing persistent notification
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
Expand Down
76 changes: 72 additions & 4 deletions app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package com.chiller3.bcr

import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
import kotlin.random.Random

class RecorderInCallService : InCallService(), RecorderThread.OnRecordingCompletedListener {
companion object {
private val TAG = RecorderInCallService::class.java.simpleName

private val ACTION_PAUSE = "${RecorderInCallService::class.java.canonicalName}.pause"
private val ACTION_RESUME = "${RecorderInCallService::class.java.canonicalName}.resume"
private const val EXTRA_TOKEN = "token"
}

private lateinit var prefs: Preferences
Expand All @@ -28,6 +34,16 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
*/
private var pendingExit = 0

/**
* Token value for all intents received by this instance of the service.
*
* For the pause/resume functionality, we cannot use a bound service because [InCallService]
* uses its own non-extensible [onBind] implementation. So instead, we rely on [onStartCommand].
* However, because this service is required to be exported, the intents could potentially come
* from third party apps and we don't want those interfering with the recordings.
*/
private val token = Random.Default.nextBytes(128)

private val callback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
super.onStateChanged(call, state)
Expand Down Expand Up @@ -55,13 +71,54 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
}
}

private fun createBaseIntent(): Intent =
Intent(this, RecorderInCallService::class.java).apply {
putExtra(EXTRA_TOKEN, token)
}

private fun createPauseIntent(): Intent =
createBaseIntent().apply {
action = ACTION_PAUSE
}

private fun createResumeIntent(): Intent =
createBaseIntent().apply {
action = ACTION_RESUME
}

override fun onCreate() {
super.onCreate()

prefs = Preferences(this)
notifications = Notifications(this)
}

/** Handle intents triggered from notification actions for pausing and resuming. */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
val receivedToken = intent?.getByteArrayExtra(EXTRA_TOKEN)
if (!receivedToken.contentEquals(token)) {
throw IllegalArgumentException("Invalid token")
}

when (val action = intent?.action) {
ACTION_PAUSE, ACTION_RESUME -> {
for ((_, recorder) in recorders) {
recorder.isPaused = action == ACTION_PAUSE
}
updateForegroundState()
}
else -> throw IllegalArgumentException("Invalid action: $action")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle intent: $intent", e)
}

// All actions are oneshot actions that should not be redelivered if a restart occurs
stopSelf(startId)
return START_NOT_STICKY
}

/**
* Always called when the telephony framework becomes aware of a new call.
*
Expand Down Expand Up @@ -197,10 +254,21 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
if (recorders.isEmpty() && pendingExit == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
startForeground(1, notifications.createPersistentNotification(
R.string.notification_recording_in_progress,
R.drawable.ic_launcher_quick_settings,
))
if (recorders.any { it.value.isPaused }) {
startForeground(1, notifications.createPersistentNotification(
R.string.notification_recording_paused,
R.drawable.ic_launcher_quick_settings,
R.string.notification_action_resume,
createResumeIntent(),
))
} else {
startForeground(1, notifications.createPersistentNotification(
R.string.notification_recording_in_progress,
R.drawable.ic_launcher_quick_settings,
R.string.notification_action_pause,
createPauseIntent(),
))
}
notifications.vibrateIfEnabled(Notifications.CHANNEL_ID_PERSISTENT)
}
}
Expand Down
31 changes: 24 additions & 7 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ class RecorderThread(
@Volatile private var isCancelled = false
private var captureFailed = false

// Pause state
@Volatile var isPaused = false
set(result) {
Log.d(tag, "Pause state updated: $isPaused")
field = result
}

// Timestamp
private lateinit var callTimestamp: ZonedDateTime
private var formatter = FORMATTER
Expand Down Expand Up @@ -603,7 +610,8 @@ class RecorderThread(
* @throws Exception if the audio recorder or encoder encounters an error
*/
private fun encodeLoop(audioRecord: AudioRecord, encoder: Encoder, bufSize: Int) {
var numFrames = 0L
var numFramesTotal = 0L
var numFramesEncoded = 0L
val frameSize = audioRecord.format.frameSizeInBytesCompat

// Use a slightly larger buffer to reduce the chance of problems under load
Expand Down Expand Up @@ -634,18 +642,25 @@ class RecorderThread(
buffer.limit(n)

val encodeBegin = System.nanoTime()
encoder.encode(buffer, false)

// If paused, keep recording, but throw away the data
if (!isPaused) {
encoder.encode(buffer, false)
numFramesEncoded += n / frameSize
}

numFramesTotal += n / frameSize

encodeElapsed = System.nanoTime() - encodeBegin

buffer.clear()

numFrames += n / frameSize
}

val totalElapsed = System.nanoTime() - begin
if (encodeElapsed > bufferNs) {
Log.w(tag, "${encoder.javaClass.simpleName} took too long: " +
"timestamp=${numFrames.toDouble() / audioRecord.sampleRate}s, " +
"timestampTotal=${numFramesTotal.toDouble() / audioRecord.sampleRate}s, " +
"timestampEncode=${numFramesEncoded.toDouble() / audioRecord.sampleRate}s, " +
"buffer=${bufferNs / 1_000_000.0}ms, " +
"total=${totalElapsed / 1_000_000.0}ms, " +
"record=${recordElapsed / 1_000_000.0}ms, " +
Expand All @@ -658,8 +673,10 @@ class RecorderThread(
buffer.limit(buffer.position())
encoder.encode(buffer, true)

val durationSecs = numFrames.toDouble() / audioRecord.sampleRate
Log.d(tag, "Input complete after ${"%.1f".format(durationSecs)}s")
val durationSecsTotal = numFramesTotal.toDouble() / audioRecord.sampleRate
val durationSecsEncoded = numFramesEncoded.toDouble() / audioRecord.sampleRate
Log.d(tag, "Input complete after ${"%.1f".format(durationSecsTotal)}s " +
"(${"%.1f".format(durationSecsEncoded)}s encoded)")
}

companion object {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,15 @@
<string name="notification_channel_success_name">Success alerts</string>
<string name="notification_channel_success_desc">Alerts for successful call recordings</string>
<string name="notification_recording_in_progress">Call recording in progress</string>
<string name="notification_recording_paused">Call recording paused</string>
<string name="notification_recording_failed">Failed to record call</string>
<string name="notification_recording_succeeded">Successfully recorded call</string>
<string name="notification_internal_android_error">The recording failed in an internal Android component (%s). This device or firmware might not support call recording.</string>
<string name="notification_action_open">Open</string>
<string name="notification_action_share">Share</string>
<string name="notification_action_delete">Delete</string>
<string name="notification_action_pause">Pause</string>
<string name="notification_action_resume">Resume</string>

<!-- Quick settings tile -->
<string name="quick_settings_label">Call recording</string>
Expand Down

0 comments on commit 37dd024

Please sign in to comment.