From 37dd0248614f89dfa78ab384510979a02bd1b928 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Tue, 27 Dec 2022 18:44:15 -0500 Subject: [PATCH] Add support for pausing/resuming the recording 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 --- .../java/com/chiller3/bcr/Notifications.kt | 20 ++++- .../com/chiller3/bcr/RecorderInCallService.kt | 76 ++++++++++++++++++- .../java/com/chiller3/bcr/RecorderThread.kt | 31 ++++++-- app/src/main/res/values/strings.xml | 3 + 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/chiller3/bcr/Notifications.kt b/app/src/main/java/com/chiller3/bcr/Notifications.kt index 77a949031..f73e48e85 100644 --- a/app/src/main/java/com/chiller3/bcr/Notifications.kt +++ b/app/src/main/java/com/chiller3/bcr/Notifications.kt @@ -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 @@ -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) diff --git a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt index aa268b674..a15899cfa 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt @@ -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 @@ -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) @@ -55,6 +71,21 @@ 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() @@ -62,6 +93,32 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet 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. * @@ -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) } } diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index 4d8e3720e..7a1b8cd8d 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -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 @@ -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 @@ -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, " + @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16e34380a..4c73d7ea7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,12 +47,15 @@ Success alerts Alerts for successful call recordings Call recording in progress + Call recording paused Failed to record call Successfully recorded call The recording failed in an internal Android component (%s). This device or firmware might not support call recording. Open Share Delete + Pause + Resume Call recording