Skip to content

Commit

Permalink
Add support for customizing the output filename
Browse files Browse the repository at this point in the history
This is a hidden feature and will not be exposed via BCR's GUI. A user
can customize the output filename by copying the default template to
<output directory>/bcr.properties and then editing the file.

Fixes: #187

Signed-off-by: Andrew Gunnerson <chillermillerlong@hotmail.com>
  • Loading branch information
chenxiaolong committed Dec 8, 2022
1 parent aab39c5 commit 588d3f6
Show file tree
Hide file tree
Showing 4 changed files with 277 additions and 55 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ BCR is a simple Android call recording app for rooted devices or devices running

As the name alludes, BCR intends to be a basic as possible. The project will have succeeded at its goal if the only updates it ever needs are for compatibility with new Android versions. Thus, many potentially useful features will never be implemented, such as:

* Changing the filename format
* Support for old Android versions (support is dropped as soon as maintenance becomes cumbersome)
* Workarounds for [OEM-specific battery optimization and app killing behavior](https://dontkillmyapp.com/)
* Workarounds for devices that don't support the [`VOICE_CALL` audio source](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_CALL) (eg. using microphone + speakerphone)
Expand Down
119 changes: 119 additions & 0 deletions app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.chiller3.bcr

import android.content.Context
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import java.util.*
import java.util.regex.Pattern

class FilenameTemplate private constructor(props: Properties) {
private val components = arrayListOf<Component>()

init {
Log.d(TAG, "Filename template: $props")

while (true) {
val index = components.size
val text = props.getProperty("filename.$index.text") ?: break
val default = props.getProperty("filename.$index.default")
val prefix = props.getProperty("filename.$index.prefix")
val suffix = props.getProperty("filename.$index.suffix")

components.add(Component(text, default, prefix, suffix))
}

if (components.isEmpty() || !components[0].text.startsWith(VAR_DATE)) {
throw IllegalArgumentException("The first filename component must begin with $VAR_DATE")
}

Log.d(TAG, "Loaded filename components: $components")
}

fun evaluate(getVar: (String) -> String?): String {
val varCache = hashMapOf<String, String?>()
val getVarCached = { name: String ->
varCache.getOrPut(name) {
getVar(name)
}
}

return buildString {
for (c in components) {
var text = evalVars(c.text, getVarCached)
if (text.isEmpty() && c.default != null) {
text = evalVars(c.default, getVarCached)
}
if (text.isNotEmpty()) {
if (c.prefix != null) {
append(evalVars(c.prefix, getVarCached))
}
append(text)
if (c.suffix != null) {
append(evalVars(c.suffix, getVarCached))
}
}
}
}
}

private data class Component(
val text: String,
val default: String?,
val prefix: String?,
val suffix: String?,
)

companion object {
private val TAG = FilenameTemplate::class.java.simpleName

private val VAR_PATTERN = Pattern.compile("""\${'$'}\{(\w+)\}""")
private val VAR_DATE = "${'$'}{date}"

private fun evalVars(input: String, getVar: (String) -> String?): String =
StringBuffer().run {
val m = VAR_PATTERN.matcher(input)

while (m.find()) {
val name = m.group(1)!!
val replacement = getVar(name)

m.appendReplacement(this, replacement ?: "")
}

m.appendTail(this)

toString()
}

fun load(context: Context): FilenameTemplate {
val props = Properties()

val prefs = Preferences(context)
val outputDir = prefs.outputDir?.let {
// Only returns null on API <21
DocumentFile.fromTreeUri(context, it)!!
} ?: DocumentFile.fromFile(prefs.defaultOutputDir)

val templateFile = outputDir.findFile("bcr.properties")
if (templateFile != null) {
try {
Log.d(TAG, "Loading custom filename template: ${templateFile.uri}")

context.contentResolver.openInputStream(templateFile.uri)?.use {
props.load(it)
return FilenameTemplate(props)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load custom filename template", e)
}
}

Log.d(TAG, "Loading default filename template")

context.resources.openRawResource(R.raw.filename_template).use {
props.load(it)
return FilenameTemplate(props)
}
}
}
}
143 changes: 89 additions & 54 deletions app/src/main/java/com/chiller3/bcr/RecorderThread.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class RecorderThread(

// Filename
private val filenameLock = Object()
private var pendingCallDetails: Call.Details? = null
private lateinit var filenameTemplate: FilenameTemplate
private lateinit var filename: String
private val redactions = HashMap<String, String>()

Expand Down Expand Up @@ -110,67 +112,83 @@ class RecorderThread(
*/
fun onCallDetailsChanged(details: Call.Details) {
synchronized(filenameLock) {
redactions.clear()
if (!this::filenameTemplate.isInitialized) {
// Thread hasn't started yet, so we haven't loaded the filename template
pendingCallDetails = details
return
}

filename = buildString {
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
append(FORMATTER.format(callTimestamp))
redactions.clear()

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 -> {}
filename = filenameTemplate.evaluate {
when (it) {
"date" -> {
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
return@evaluate FORMATTER.format(callTimestamp)
}
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE)
== PackageManager.PERMISSION_GRANTED
&& context.packageManager.hasSystemFeature(
PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) {
val subscriptionManager = context.getSystemService(SubscriptionManager::class.java)

// 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 subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId)

append("_sim")
append(subscriptionInfo.simSlotIndex + 1)
"direction" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
when (details.callDirection) {
Call.Details.DIRECTION_INCOMING -> return@evaluate "in"
Call.Details.DIRECTION_OUTGOING -> return@evaluate "out"
Call.Details.DIRECTION_UNKNOWN -> {}
}
}
}
}

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

redactions[details.handle.schemeSpecificPart] = "<phone number>"
}

val callerName = details.callerDisplayName?.trim()
if (!callerName.isNullOrBlank()) {
append('_')
append(callerName)

redactions[callerName] = "<caller name>"
}
"sim_slot" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE)
== PackageManager.PERMISSION_GRANTED
&& context.packageManager.hasSystemFeature(
PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)) {
val subscriptionManager = context.getSystemService(SubscriptionManager::class.java)

// 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 subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId)

return@evaluate "${subscriptionInfo.simSlotIndex + 1}"
}
}
}
"phone_number" -> {
if (details.handle?.scheme == PhoneAccount.SCHEME_TEL) {
redactions[details.handle.schemeSpecificPart] = "<phone number>"

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val contactName = details.contactDisplayName?.trim()
if (!contactName.isNullOrBlank()) {
append('_')
append(contactName)
return@evaluate details.handle.schemeSpecificPart
}
}
"caller_name" -> {
val callerName = details.callerDisplayName?.trim()
if (!callerName.isNullOrBlank()) {
redactions[callerName] = "<caller name>"

redactions[contactName] = "<contact name>"
return@evaluate callerName
}
}
"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
}
}
}
else -> {
Log.w(tag, "Unknown filename template variable: $it")
}
}

null
}
// 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.
// 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()

Log.i(tag, "Updated filename due to call details change: ${redact(filename)}")
Expand All @@ -190,7 +208,14 @@ class RecorderThread(
if (isCancelled) {
Log.i(tag, "Recording cancelled before it began")
} else {
val initialFilename = synchronized(filenameLock) { filename }
val initialFilename = synchronized(filenameLock) {
filenameTemplate = FilenameTemplate.load(context)

onCallDetailsChanged(pendingCallDetails!!)
pendingCallDetails = null

filename
}
val outputFile = createFileInDefaultDir(initialFilename, format.mimeTypeContainer)
resultUri = outputFile.uri

Expand Down Expand Up @@ -609,10 +634,20 @@ class RecorderThread(
return null
}

val second = name.indexOf('_', first + 1)
// The user might not have a delimiter in the template, but we know for sure the
// date ends at least 4 digits after the +/- in the date offset
var second = name.indexOfAny(charArrayOf('-', '+'), first + 1)
if (second < 0) {
return null
}
second += 5

// If there are two additional digits, then assume they are part of the offset
if (second + 2 <= name.length
&& name[second].isDigit()
&& name[second + 1].isDigit()) {
second += 2
}

return ZonedDateTime.parse(name.substring(0, second), FORMATTER)
} catch (e: DateTimeParseException) {
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/res/raw/filename_template.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# This file specifies the filename template for BCR's output files. To change
# the default filename template, copy this file to `bcr.properties` in the
# output directory and edit it to your liking.
#
# Syntax/rules:
# 1. The filename components start at 0.
# 2. Do not skip numbers for the components. For example, if there are 4
# components: 0, 1, 2, 4, then 4 is ignored because there's a gap in the
# middle.
# 3. Blank lines and lines beginning with # are ignored.
# 4. The file extension is not part of this template. File extensions are
# automatically determined by Android.
#
# Available options:
# - filename.<num>.text: The text to add to the filename. Variables are included
# with the ${...} syntax. If a variable is not defined, then it is replaced
# with an empty string.
# - filename.<num>.default: If `text` is empty, then this value is used as a
# fallback.
# - filename.<num>.prefix: If `text` (and `default`) are not empty, then this
# value is added to the beginning.
# - filename.<num>.suffix: If `text` (and `default`) are not empty, then this
# value is added to the end.
#
# Troubleshooting:
# If there is a syntax error, BCR will ignore the custom template and fall
# back to the default. To find out more details, enable debug mode by long
# pressing BCR's version number. After the next phone call, BCR will create a
# log file in the output directory. Search for `FilenameTemplate` in the log
# file.

# Time of call. Must always be the first component.
filename.0.text = ${date}

# Call direction, which is either `in` or `out`. Only defined on Android 10+.
filename.1.text = ${direction}
filename.1.prefix = _

# SIM slot number. Only defined on Android 11+ if multiple SIMs are active and
# the user has granted the phone permission.
filename.2.text = ${sim_slot}
filename.2.prefix = _sim

# Phone number of the other party in the call.
filename.3.text = ${phone_number}
filename.3.prefix = _

# Caller ID as provided by CNAP from the carrier.
filename.4.text = ${caller_name}
filename.4.prefix = _

# Contact name. Only defined on Android 11+ if the user has granted the contacts
# permission.
filename.5.text = ${contact_name}
filename.5.prefix = _

################################################################################

# Example: Add the call direction to the filename with a leading underscore. If
# the call direction can't be determined, then add "unknown" instead.
#filename.<num>.text = ${direction}
#filename.<num>.default = unknown
#filename.<num>.prefix = _

# Example: Add the contact name to the filename if it exists. Otherwise, fall
# back to the caller ID.
#filename.<num>.text = ${contact_name}
#filename.<num>.default = ${caller_name}
#filename.<num>.prefix = _

0 comments on commit 588d3f6

Please sign in to comment.