From 588d3f69b71a2b0691b0a0e29b397d32b7a8dd3f Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 7 Dec 2022 20:25:22 -0500 Subject: [PATCH] Add support for customizing the output filename 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 /bcr.properties and then editing the file. Fixes: #187 Signed-off-by: Andrew Gunnerson --- README.md | 1 - .../java/com/chiller3/bcr/FilenameTemplate.kt | 119 +++++++++++++++ .../java/com/chiller3/bcr/RecorderThread.kt | 143 +++++++++++------- .../main/res/raw/filename_template.properties | 69 +++++++++ 4 files changed, 277 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt create mode 100644 app/src/main/res/raw/filename_template.properties diff --git a/README.md b/README.md index c3f6d2922..df7d1f3a6 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt b/app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt new file mode 100644 index 000000000..6b1994623 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt @@ -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() + + 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() + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index c698bc92c..ee1f97e77 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -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() @@ -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] = "" - } - - val callerName = details.callerDisplayName?.trim() - if (!callerName.isNullOrBlank()) { - append('_') - append(callerName) - - redactions[callerName] = "" - } + "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] = "" - 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] = "" - redactions[contactName] = "" + return@evaluate callerName + } + } + "contact_name" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val contactName = details.contactDisplayName?.trim() + if (!contactName.isNullOrBlank()) { + redactions[contactName] = "" + + 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)}") @@ -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 @@ -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) { diff --git a/app/src/main/res/raw/filename_template.properties b/app/src/main/res/raw/filename_template.properties new file mode 100644 index 000000000..619e8c812 --- /dev/null +++ b/app/src/main/res/raw/filename_template.properties @@ -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..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..default: If `text` is empty, then this value is used as a +# fallback. +# - filename..prefix: If `text` (and `default`) are not empty, then this +# value is added to the beginning. +# - filename..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..text = ${direction} +#filename..default = unknown +#filename..prefix = _ + +# Example: Add the contact name to the filename if it exists. Otherwise, fall +# back to the caller ID. +#filename..text = ${contact_name} +#filename..default = ${caller_name} +#filename..prefix = _