Skip to content

Commit

Permalink
Improve conference call handling
Browse files Browse the repository at this point in the history
* RecorderInCallService and RecorderThread have been updated to be aware
  of parent/child relationships of calls. Previously, child calls were
  treated as independent calls, so a conference call would result in
  separate (but identical in audio) recordings for the parent conference
  call and every call it was merged from.
* RecorderInCallService and RecorderThread now mute the recording when
  the corresponding call is in the HOLDING state. This prevents audio
  from a different call from being recorded while the call is on hold
  (due to call waiting).

Fixes: #284

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed Apr 11, 2023
1 parent 6387241 commit ebf87ed
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 29 deletions.
15 changes: 13 additions & 2 deletions app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,16 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet

Log.d(TAG, "handleStateChange: $call, $state, $callState")

if (callState == Call.STATE_ACTIVE) {
if (call.parent != null) {
Log.v(TAG, "Ignoring state change of conference call child")
} else if (callState == Call.STATE_ACTIVE) {
startRecording(call)
} else if (callState == Call.STATE_DISCONNECTING || callState == Call.STATE_DISCONNECTED) {
// This is necessary because onCallRemoved() might not be called due to firmware bugs
requestStopRecording(call)
}

recorders[call]?.isHolding = callState == Call.STATE_HOLDING
}

/**
Expand Down Expand Up @@ -243,7 +247,14 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
* The recording thread uses call details for generating filenames.
*/
private fun handleDetailsChange(call: Call, details: Call.Details) {
recorders[call]?.onCallDetailsChanged(details)
val parentCall = call.parent
val recorder = if (parentCall != null) {
recorders[parentCall]
} else {
recorders[call]
}

recorder?.onCallDetailsChanged(call, details)
}

/**
Expand Down
116 changes: 89 additions & 27 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ import android.os.Process as AndroidProcess
* kept in the object.
* @param listener Used for sending completion notifications. The listener is called from this
* thread, not the main thread.
* @param call Used only for determining the output filename and is not saved.
* @param parentCall Used for determining the output filename. References to it and its children are
* kept in the object.
*/
class RecorderThread(
private val context: Context,
private val listener: OnRecordingCompletedListener,
call: Call,
private val parentCall: Call,
) : Thread(RecorderThread::class.java.simpleName) {
private val tag = "${RecorderThread::class.java.simpleName}/${id}"
private val prefs = Preferences(context)
Expand All @@ -70,14 +71,17 @@ class RecorderThread(
}
private var wasEverResumed = !isPaused

// Call state
@Volatile var isHolding = false
private val isConference = parentCall.details.hasProperty(Call.Details.PROPERTY_CONFERENCE)

// Timestamp
private lateinit var callTimestamp: ZonedDateTime
private var formatter = FORMATTER

// Filename
private val filenameLock = Object()
private var pendingCallDetails: Call.Details? = null
private lateinit var lastCallDetails: Call.Details
private var callDetails = mutableMapOf<Call, Call.Details>()
private lateinit var filenameTemplate: FilenameTemplate
private lateinit var filename: String
private val redactions = HashMap<String, String>()
Expand Down Expand Up @@ -109,10 +113,15 @@ class RecorderThread(
private lateinit var logcatProcess: Process

init {
Log.i(tag, "Created thread for call: $call")
Log.i(tag, "Created thread for call: $parentCall")
Log.i(tag, "Initially paused: $isPaused")

onCallDetailsChanged(call.details)
callDetails[parentCall] = parentCall.details
if (isConference) {
for (childCall in parentCall.children) {
callDetails[childCall] = childCall.details
}
}

val savedFormat = Format.fromPreferences(prefs)
format = savedFormat.first
Expand All @@ -123,21 +132,43 @@ class RecorderThread(
* Update [filename] with information from [details].
*
* This function holds a lock on [filenameLock] until it returns.
*
* @param call Either the parent call or a child of the parent (for conference calls)
* @param details The updated call details belonging to [call]
*/
fun onCallDetailsChanged(details: Call.Details) {
fun onCallDetailsChanged(call: Call, details: Call.Details) {
if (call !== parentCall && call.parent !== parentCall) {
throw IllegalStateException("Not the parent call nor one of its children: $call")
}

synchronized(filenameLock) {
callDetails[call] = details

updateFilename()
}
}

private fun updateFilename() {
synchronized(filenameLock) {
if (!this::filenameTemplate.isInitialized) {
// Thread hasn't started yet, so we haven't loaded the filename template
pendingCallDetails = details
return
}

lastCallDetails = details
val parentDetails = callDetails[parentCall]!!
val displayDetails = if (isConference) {
callDetails.entries.asSequence()
.filter { it.key != parentCall }
.map { it.value }
.toList()
} else {
listOf(parentDetails)
}

filename = filenameTemplate.evaluate {
when {
it == "date" || it.startsWith("date:") -> {
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
val instant = Instant.ofEpochMilli(parentDetails.creationTimeMillis)
callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())

val colon = it.indexOf(":")
Expand All @@ -158,7 +189,7 @@ class RecorderThread(
}
it == "direction" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
when (details.callDirection) {
when (parentDetails.callDirection) {
Call.Details.DIRECTION_INCOMING -> return@evaluate "in"
Call.Details.DIRECTION_OUTGOING -> return@evaluate "out"
Call.Details.DIRECTION_UNKNOWN -> {}
Expand All @@ -176,36 +207,61 @@ class RecorderThread(
// Only append SIM slot ID if the device has multiple active SIMs
if (subscriptionManager.activeSubscriptionInfoCount > 1) {
val telephonyManager = context.getSystemService(TelephonyManager::class.java)
val subscriptionId = telephonyManager.getSubscriptionId(details.accountHandle)
val subscriptionId = telephonyManager.getSubscriptionId(parentDetails.accountHandle)
val subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId)

return@evaluate "${subscriptionInfo.simSlotIndex + 1}"
}
}
}
it == "phone_number" -> {
if (details.handle?.scheme == PhoneAccount.SCHEME_TEL) {
redactions[details.handle.schemeSpecificPart] = "<phone number>"
val joined = displayDetails.asSequence()
.map { d -> d.handle }
.filter { uri -> uri?.scheme == PhoneAccount.SCHEME_TEL }
.map { uri -> uri.schemeSpecificPart }
.filterNotNull()
.joinToString(",")
if (joined.isNotEmpty()) {
redactions[joined] = if (isConference) {
"<conference phone numbers>"
} else {
"<phone number>"
}

return@evaluate details.handle.schemeSpecificPart
return@evaluate joined
}
}
it == "caller_name" -> {
val callerName = details.callerDisplayName?.trim()
if (!callerName.isNullOrBlank()) {
redactions[callerName] = "<caller name>"
val joined = displayDetails.asSequence()
.map { d -> d.callerDisplayName?.trim() }
.filter { n -> !n.isNullOrEmpty() }
.joinToString(",")
if (joined.isNotEmpty()) {
redactions[joined] = if (isConference) {
"<conference caller names>"
} else {
"<caller name>"
}

return@evaluate callerName
return@evaluate joined
}
}
it == "contact_name" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val contactName = details.contactDisplayName?.trim()
if (!contactName.isNullOrBlank()) {
redactions[contactName] = "<contact name>"

return@evaluate contactName
val joined = displayDetails.asSequence()
.map { d -> d.contactDisplayName?.trim() }
.filter { n -> !n.isNullOrEmpty() }
.joinToString(",")
if (joined.isNotEmpty()) {
redactions[joined] = if (isConference) {
"<conference contact names>"
} else {
"<contact name>"
}

return@evaluate joined
}

}
}
else -> {
Expand Down Expand Up @@ -235,8 +291,7 @@ class RecorderThread(
// checking for the existence of the template may take >500ms.
filenameTemplate = FilenameTemplate.load(context, false)

onCallDetailsChanged(pendingCallDetails!!)
pendingCallDetails = null
updateFilename()
}

startLogcat()
Expand All @@ -260,7 +315,7 @@ class RecorderThread(
val finalFilename = synchronized(filenameLock) {
filenameTemplate = FilenameTemplate.load(context, true)

onCallDetailsChanged(lastCallDetails)
updateFilename()
filename
}
if (finalFilename != initialFilename) {
Expand Down Expand Up @@ -553,6 +608,7 @@ class RecorderThread(
// Use a slightly larger buffer to reduce the chance of problems under load
val factor = 2
val buffer = ByteBuffer.allocateDirect(bufSize * factor)
val zeroes = ByteArray(buffer.capacity())
val bufferFrames = buffer.capacity().toLong() / frameSize
val bufferNs = bufferFrames * 1_000_000_000L / audioRecord.sampleRate
Log.d(tag, "Buffer is ${buffer.capacity()} bytes, $bufferFrames frames, ${bufferNs}ns")
Expand All @@ -577,6 +633,12 @@ class RecorderThread(
} else {
buffer.limit(n)

// If holding, mute the audio so we don't capture data for the wrong call
if (isHolding) {
buffer.clear()
buffer.put(zeroes, 0, n)
}

val encodeBegin = System.nanoTime()

// If paused, keep recording, but throw away the data
Expand Down

0 comments on commit ebf87ed

Please sign in to comment.