Skip to content

Commit

Permalink
Add contact name to output filename
Browse files Browse the repository at this point in the history
This is not enabled by default and there's no user interface for
enabling it. The user can only enable the feature by manually going to
the system settings and enabling the Contacts permission for BCR.

Issue: #28
Signed-off-by: Andrew Gunnerson <chillermillerlong@hotmail.com>
  • Loading branch information
chenxiaolong committed May 28, 2022
1 parent 6d2e907 commit fb226b2
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 48 deletions.
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
android:name="android.permission.CONTROL_INCALL_EXPERIENCE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet

handleStateChange(call)
}

override fun onDetailsChanged(call: Call, details: Call.Details) {
super.onDetailsChanged(call, details)
Log.d(TAG, "onDetailsChanged: $call, $details")

handleDetailsChange(call, details)
}
}

override fun onCallAdded(call: Call) {
Expand Down Expand Up @@ -95,6 +102,16 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
}
}

/**
* Notify recording thread of call details changes.
*
* The recording thread uses call details for generating filenames.
*/
private fun handleDetailsChange(call: Call, details: Call.Details) {
// The call may not exist if this is called after handleStateChange with STATE_DISCONNECTING
recorders[call]?.onCallDetailsChanged(details)
}

/**
* Move to foreground, creating a persistent notification, when there are active calls or
* recording threads that haven't finished exiting yet.
Expand Down
143 changes: 95 additions & 48 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,79 +49,126 @@ class RecorderThread(
private var captureFailed = false

// Filename
private val handleUri: Uri = call.details.handle
private val creationTime: Long = call.details.creationTimeMillis
private val direction: String? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
when (call.details.callDirection) {
Call.Details.DIRECTION_INCOMING -> "in"
Call.Details.DIRECTION_OUTGOING -> "out"
else -> null
}
} else {
null
}
private val displayName: String? = call.details.callerDisplayName
private val filenameLock = Object()
private lateinit var filename: String

// Codec
private val codec: Codec
private val codecParam: UInt?

init {
Log.i(TAG, "[${id}] Created thread for call: $call")
logI("Created thread for call: $call")

onCallDetailsChanged(call.details)

val savedCodec = Codecs.fromPreferences(context)
codec = savedCodec.first
codecParam = savedCodec.second
}

private fun getFilename(): String =
buildString {
val instant = Instant.ofEpochMilli(creationTime)
append(FORMATTER.format(ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())))
private fun logD(msg: String) {
Log.d(TAG, "[${id}] $msg")
}

if (direction != null) {
append('_')
append(direction)
}
private fun logE(msg: String, throwable: Throwable) {
Log.e(TAG, "[${id}] $msg", throwable)
}

if (handleUri.scheme == PhoneAccount.SCHEME_TEL) {
append('_')
append(handleUri.schemeSpecificPart)
}
private fun logE(msg: String) {
Log.e(TAG, "[${id}] $msg")
}

private fun logI(msg: String) {
Log.i(TAG, "[${id}] $msg")
}

private fun logW(msg: String) {
Log.w(TAG, "[${id}] $msg")
}

/**
* Update [filename] with information from [details].
*
* This function holds a lock on [filenameLock] until it returns.
*/
fun onCallDetailsChanged(details: Call.Details) {
synchronized(filenameLock) {
filename = buildString {
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
append(FORMATTER.format(ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())))

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
when (details.callDirection) {
Call.Details.DIRECTION_INCOMING -> append("_in")
Call.Details.DIRECTION_OUTGOING -> append("_out")
Call.Details.DIRECTION_UNKNOWN -> {}
}
}

if (details.handle.scheme == PhoneAccount.SCHEME_TEL) {
append('_')
append(details.handle.schemeSpecificPart)
}

// AOSP's SAF automatically replaces invalid characters with underscores, but just in
// case an OEM fork breaks that, do the replacement ourselves to prevent directory
// traversal attacks.
val name = displayName?.replace('/', '_')?.trim()
if (!name.isNullOrBlank()) {
append('_')
append(name)
val callerName = details.callerDisplayName?.trim()
if (!callerName.isNullOrBlank()) {
append('_')
append(callerName)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val contactName = details.contactDisplayName?.trim()
if (!contactName.isNullOrBlank()) {
append('_')
append(contactName)
}
}
}
// AOSP's SAF automatically replaces invalid characters with underscores, but just
// in case an OEM fork breaks that, do the replacement ourselves to prevent
// directory traversal attacks.
.replace('/', '_').trim()

logI("Updated filename due to call details change: $filename")
}
}

override fun run() {
var success = false
var resultUri: Uri? = null

try {
Log.i(TAG, "[${id}] Recording thread started")
logI("Recording thread started")

if (isCancelled) {
Log.i(TAG, "[${id}] Recording cancelled before it began")
logI("Recording cancelled before it began")
} else {
val (uri, pfd) = openOutputFile(getFilename())
resultUri = uri
val initialFilename = synchronized(filenameLock) { filename }

val (file, pfd) = openOutputFile(initialFilename)
resultUri = file.uri

pfd.use {
recordUntilCancelled(it)
}

val finalFilename = synchronized(filenameLock) { filename }
if (finalFilename != initialFilename) {
logI("Renaming $initialFilename to $finalFilename")

if (file.renameTo(finalFilename)) {
resultUri = file.uri
} else {
logW("Failed to rename to final filename: $finalFilename")
}
}

success = !captureFailed
}
} catch (e: Exception) {
Log.e(TAG, "[${id}] Error during recording", e)
logE("Error during recording", e)
} finally {
Log.i(TAG, "[${id}] Recording thread completed")
logI("Recording thread completed")

if (success) {
listener.onRecordingCompleted(this, resultUri!!)
Expand All @@ -143,7 +190,7 @@ class RecorderThread(
isCancelled = true
}

data class OutputFile(val uri: Uri, val pfd: ParcelFileDescriptor)
data class OutputFile(val file: DocumentFile, val pfd: ParcelFileDescriptor)

/**
* Try to create and open a new output file in the user-chosen directory if possible and fall
Expand All @@ -159,12 +206,12 @@ class RecorderThread(
val userDir = DocumentFile.fromTreeUri(context, userUri)
return openOutputFileInDir(userDir!!, name)
} catch (e: Exception) {
Log.e(TAG, "Failed to open file in user-specified directory: $userUri", e)
logE("Failed to open file in user-specified directory: $userUri", e)
}
}

val fallbackDir = DocumentFile.fromFile(Preferences.getDefaultOutputDir(context))
Log.d(TAG, "Using fallback directory: ${fallbackDir.uri}")
logD("Using fallback directory: ${fallbackDir.uri}")

return openOutputFileInDir(fallbackDir, name)
}
Expand All @@ -180,7 +227,7 @@ class RecorderThread(
?: throw IOException("Failed to create file in ${directory.uri}")
val pfd = context.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file at ${file.uri}")
return OutputFile(file.uri, pfd)
return OutputFile(file, pfd)
}

/**
Expand Down Expand Up @@ -275,14 +322,14 @@ class RecorderThread(
val maxRead = min(maxSamplesInBytes, buffer.remaining())
val n = audioRecord.read(buffer, maxRead)
if (n < 0) {
Log.e(TAG, "Error when reading samples from ${audioRecord}: $n")
logE("Error when reading samples from ${audioRecord}: $n")
isCancelled = true
captureFailed = true
} else if (n == 0) {
// This should never be hit because AOSP guarantees that MediaCodec's
// ByteBuffers are direct buffers, but this is not publicly documented
// behavior
Log.e(TAG, "MediaCodec's ByteBuffer was not a direct buffer")
logE( "MediaCodec's ByteBuffer was not a direct buffer")
isCancelled = true
} else {
val frames = n / frameSize
Expand All @@ -291,7 +338,7 @@ class RecorderThread(

if (isCancelled) {
val duration = "%.1f".format(inputTimestamp / 1_000_000.0)
Log.d(TAG, "Input complete after ${duration}s")
logD("Input complete after ${duration}s")
inputComplete = true
}

Expand All @@ -305,7 +352,7 @@ class RecorderThread(
// software encoder to crash with SIGABRT
mediaCodec.queueInputBuffer(inputBufferId, 0, n, 0, flags)
} else if (inputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
Log.w(TAG, "Unexpected input buffer dequeue error: $inputBufferId")
logW("Unexpected input buffer dequeue error: $inputBufferId")
}
}

Expand All @@ -323,11 +370,11 @@ class RecorderThread(
}
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val outputFormat = mediaCodec.outputFormat
Log.d(TAG, "Output format changed to: $outputFormat")
logD("Output format changed to: $outputFormat")
trackIndex = container.addTrack(outputFormat)
container.start()
} else if (outputBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
Log.w(TAG, "Unexpected output buffer dequeue error: $outputBufferId")
logW("Unexpected output buffer dequeue error: $outputBufferId")
}
}
}
Expand Down

0 comments on commit fb226b2

Please sign in to comment.