Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for pausing/resuming the recording #210

Merged
merged 1 commit into from
Dec 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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