From 9a62e3d1c7209102dc25aa45495d4879997d561f Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Jul 2023 23:14:56 -0400 Subject: [PATCH] Add support for writing call metadata to an adjacent JSON file The JSON file contains all of the metadata known to BCR (which roughly matches the information that can be used in the filename templates). This feature is disabled by default. The majority of this commit is splitting the call metadata collection out of OutputFilenameGenerator. This is something that should've been done anyway. The notification delete button logic has also been reworked so that the metadata file is also deleted. If debug mode is enabled, the log file will now also be deleted. Closes: #380 Signed-off-by: Andrew Gunnerson --- README.md | 56 ++- app/build.gradle.kts | 1 + .../chiller3/bcr/NotificationActionService.kt | 63 +-- .../java/com/chiller3/bcr/Notifications.kt | 13 +- .../main/java/com/chiller3/bcr/Preferences.kt | 8 + .../com/chiller3/bcr/RecorderInCallService.kt | 31 +- .../java/com/chiller3/bcr/RecorderThread.kt | 103 ++++- .../bcr/extension/CallDetailsExtensions.kt | 5 +- .../com/chiller3/bcr/output/CallMetadata.kt | 52 +++ .../bcr/output/CallMetadataCollector.kt | 263 ++++++++++++ .../com/chiller3/bcr/output/OutputDirUtils.kt | 45 +- .../com/chiller3/bcr/output/OutputFile.kt | 5 +- .../bcr/output/OutputFilenameGenerator.kt | 389 +++--------------- .../com/chiller3/bcr/output/PhoneNumber.kt | 61 +++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/root_preferences.xml | 6 + .../com/chiller3/bcr/template/TemplateTest.kt | 3 +- build.gradle.kts | 1 + gradle/verification-metadata.xml | 167 ++++++++ 19 files changed, 866 insertions(+), 409 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/output/CallMetadata.kt create mode 100644 app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt create mode 100644 app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt diff --git a/README.md b/README.md index ae3fa0273..149b35c75 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,8 @@ BCR supports customizing the template used for determining the output filenames * `{date}`: The timestamp of the call. The default timestamp format tries to be as unambiguous as possible and is in the form: `20230414_215701.088-0400`. A custom timestamp format can be specified with `{date:}`. For example, `{date:yyyy-MM-dd @ h.mm.ss a}` would produce `2023-04-14 @ 9.57.01 PM`. A full list of timestamp formatting characters can be found at: https://developer.android.com/reference/java/time/format/DateTimeFormatterBuilder#appendPattern(java.lang.String). * For the file retention feature to work, the date must not immediately follow another variable. For example, `{phone_number}{date}` will cause file retention to be disabled, but `{phone_number} ({date})` works because there's some text ` (` between the two variables. * If the date format is changed, the old recordings should be manually renamed or moved to another directory to ensure that they won't inadvertently be deleted. For example, if `yyMMdd_HHmmss` was changed to `HHmmss_yyMMdd`, the timestamps from the old recording's filenames would be parsed incorrectly and may get deleted. -* `{direction}`: For 1-on-1 calls, either `in` or `out` depending on if the call is an incoming or outgoing call. If the call is a conference call, then `conference` is used instead. -* `{sim_slot}`: **[Android 11+ only]** The SIM slot number for the call (counting from 1). This is only defined for multi-SIM devices that have multiple SIMs active. +* `{direction}`: **[Android 10+ only]** For 1-on-1 calls, either `in` or `out` depending on if the call is an incoming or outgoing call. If the call is a conference call, then `conference` is used instead. +* `{sim_slot}`: **[Android 11+ only]** The SIM slot number for the call (counting from 1). This is only defined for multi-SIM devices that have multiple SIMs active and if BCR is granted the Phone permission. * `{phone_number}`: The phone number for the call. This is undefined for private calls. Available formatting options: * `{phone_number:E.164}`: Default (same as just `{phone_number}`). Phone number formatted in the international E.164 format (`+`). * `{phone_number:digits_only}`: Phone number with digits only (no `+` or separators). @@ -133,6 +133,58 @@ The filename template supports specifying subdirectories using the `/` character Note that due to Android Storage Access Framework's poor performance, using subdirectories may significantly slow down the saving of the recording on some devices. On Android builds with a good SAF implementation, this may only be a few seconds. On the OEM Android build with the worst known SAF implementation, this could take several minutes. The delay is proportional to the number of files in the output directory. +## Metadata file + +If the `Write metadata file` option is enabled, BCR will write a JSON file to the output directory containing all of the details that BCR knows about the call. The file has the same name as the audio file, except with a `.json` extension. + +The JSON structure is shown in the following example. The only fields that are guaranteed to exist are the timestamp fields. If the value for a field can't be determined (eg. when a required permission is denied), then it is set to `null`. + +```jsonc +{ + // The timestamp represented as milliseconds since the Unix epoch in UTC. + "timestamp_unix_ms": 1689817988931, + + // The timestamp represented as ISO8601 (+ offset) in the local time zone. + "timestamp": "2023-07-19T21:53:08.931-04:00", + + // The call direction ("in", "out", or "conference"). + // [Android 10+ only] + "direction": "in", + + // The SIM slot used for the call. + // [Android 11+ only; requires the Phone permission] + "sim_slot": 1, + + // The name shown in the dialer's call log. This may include the business' + // name for dialers that perform reverse lookups. + // [Requires the Call Log permission] + "call_log_name": "John Doe", + + // Details about the other party or parties in the call. There will be + // multiple entries for conference calls. + "calls": [ + { + // The raw phone number as reported by Android. For outgoing calls, + // this is usually what the user typed. For incoming calls, this is + // usually E.164 formatted. This will be null for private calls. + "phone_number": "+11234567890", + + // The phone number formatted using the country-specific style. This + // will be null for private calls or if Android cannot determine the + // country. + "phone_number_formatted": "+1 123-456-7890", + + // The caller name/ID as reported by CNAP from the carrier. + "caller_name": "John Doe", + + // The contact name associated with the phone number. + // [Requires the Contacts permission] + "contact_name": "John Doe" + } + ] +} +``` + ## Advanced features This section describes BCR's hidden advanced features. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9569e8583..b3d77286a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ import org.json.JSONObject plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.parcelize") } java { diff --git a/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt b/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt index 911bfbc1d..3a2530731 100644 --- a/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt +++ b/app/src/main/java/com/chiller3/bcr/NotificationActionService.kt @@ -5,6 +5,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper @@ -16,40 +17,41 @@ class NotificationActionService : Service() { private val TAG = NotificationActionService::class.java.simpleName private val ACTION_DELETE_URI = "${NotificationActionService::class.java.canonicalName}.delete_uri" - private const val EXTRA_REDACTED = "redacted" + private const val EXTRA_FILES = "files" private const val EXTRA_NOTIFICATION_ID = "notification_id" - private fun intentFromFile(context: Context, file: OutputFile): Intent = - Intent(context, NotificationActionService::class.java).apply { - setDataAndType(file.uri, file.mimeType) - putExtra(EXTRA_REDACTED, file.redacted) - } - - fun createDeleteUriIntent(context: Context, file: OutputFile, notificationId: Int): Intent = - intentFromFile(context, file).apply { - action = ACTION_DELETE_URI - putExtra(EXTRA_NOTIFICATION_ID, notificationId) - } + fun createDeleteUriIntent( + context: Context, + files: List, + notificationId: Int, + ) = Intent(context, NotificationActionService::class.java).apply { + action = ACTION_DELETE_URI + // Unused, but guarantees filterEquals() uniqueness for use with PendingIntents + data = Uri.fromParts("notification", notificationId.toString(), null) + putExtra(EXTRA_FILES, ArrayList(files)) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } } private val handler = Handler(Looper.getMainLooper()) - private fun parseFileFromIntent(intent: Intent): OutputFile = - OutputFile( - intent.data!!, - intent.getStringExtra(EXTRA_REDACTED)!!, - intent.type!!, - ) - - private fun parseDeleteUriIntent(intent: Intent): Pair { - val file = parseFileFromIntent(intent) + private fun parseDeleteUriIntent(intent: Intent): Pair, Int> { + val files = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(EXTRA_FILES, OutputFile::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(EXTRA_FILES) + } + if (files == null) { + throw IllegalArgumentException("No files specified") + } val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) if (notificationId < 0) { throw IllegalArgumentException("Invalid notification ID: $notificationId") } - return Pair(file, notificationId) + return Pair(files, notificationId) } override fun onBind(intent: Intent?): IBinder? = null @@ -58,16 +60,19 @@ class NotificationActionService : Service() { try { when (intent?.action) { ACTION_DELETE_URI -> { - val (file, notificationId) = parseDeleteUriIntent(intent) - val documentFile = file.toDocumentFile(this) + val (files, notificationId) = parseDeleteUriIntent(intent) val notificationManager = getSystemService(NotificationManager::class.java) Thread { - Log.d(TAG, "Deleting: ${file.redacted}") - try { - documentFile.delete() - } catch (e: Exception) { - Log.w(TAG, "Failed to delete ${file.redacted}", e) + for (file in files) { + val documentFile = file.toDocumentFile(this) + + Log.d(TAG, "Deleting: ${file.redacted}") + try { + documentFile.delete() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete ${file.redacted}", e) + } } handler.post { diff --git a/app/src/main/java/com/chiller3/bcr/Notifications.kt b/app/src/main/java/com/chiller3/bcr/Notifications.kt index f6accb694..8bdeb5d02 100644 --- a/app/src/main/java/com/chiller3/bcr/Notifications.kt +++ b/app/src/main/java/com/chiller3/bcr/Notifications.kt @@ -197,6 +197,7 @@ class Notifications( @DrawableRes icon: Int, errorMsg: String?, file: OutputFile?, + additionalFiles: List, ) { val notificationId = allocateNotificationId() @@ -250,7 +251,11 @@ class Notifications( val deleteIntent = PendingIntent.getService( context, 0, - NotificationActionService.createDeleteUriIntent(context, file, notificationId), + NotificationActionService.createDeleteUriIntent( + context, + listOf(file) + additionalFiles, + notificationId, + ), PendingIntent.FLAG_IMMUTABLE, ) @@ -296,8 +301,9 @@ class Notifications( @StringRes title: Int, @DrawableRes icon: Int, file: OutputFile, + additionalFiles: List, ) { - sendAlertNotification(CHANNEL_ID_SUCCESS, title, icon, null, file) + sendAlertNotification(CHANNEL_ID_SUCCESS, title, icon, null, file, additionalFiles) vibrateIfEnabled(CHANNEL_ID_SUCCESS) } @@ -313,8 +319,9 @@ class Notifications( @DrawableRes icon: Int, errorMsg: String?, file: OutputFile?, + additionalFiles: List, ) { - sendAlertNotification(CHANNEL_ID_FAILURE, title, icon, errorMsg, file) + sendAlertNotification(CHANNEL_ID_FAILURE, title, icon, errorMsg, file, additionalFiles) vibrateIfEnabled(CHANNEL_ID_FAILURE) } diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/java/com/chiller3/bcr/Preferences.kt index 4862d8d7a..85839cc20 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/java/com/chiller3/bcr/Preferences.kt @@ -23,6 +23,7 @@ class Preferences(private val context: Context) { const val PREF_FILENAME_TEMPLATE = "filename_template" const val PREF_OUTPUT_FORMAT = "output_format" const val PREF_INHIBIT_BATT_OPT = "inhibit_batt_opt" + const val PREF_WRITE_METADATA = "write_metadata" const val PREF_VERSION = "version" const val PREF_ADD_RULE = "add_rule" @@ -270,4 +271,11 @@ class Preferences(private val context: Context) { var sampleRate: SampleRate? get() = getOptionalUint(PREF_SAMPLE_RATE)?.let { SampleRate(it) } set(sampleRate) = setOptionalUint(PREF_SAMPLE_RATE, sampleRate?.value) + + /** + * Whether to write call metadata file. + */ + var writeMetadata: Boolean + get() = prefs.getBoolean(PREF_WRITE_METADATA, false) + set(enabled) = prefs.edit { putBoolean(PREF_WRITE_METADATA, enabled) } } diff --git a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt index c2e18b581..60ee8b728 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt @@ -235,7 +235,7 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet val recorder = try { RecorderThread(this, this, call) } catch (e: Exception) { - notifyFailure(e.message, null) + notifyFailure(e.message, null, emptyList()) throw e } callsToRecorders[call] = recorder @@ -357,7 +357,7 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet } } - val message = StringBuilder(recorder.path.unredacted) + val message = StringBuilder(recorder.outputPath.unredacted) if (canShowDelete) { recorder.keepRecording?.let { @@ -404,20 +404,26 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet } } - private fun notifySuccess(file: OutputFile) { + private fun notifySuccess(file: OutputFile, additionalFiles: List) { notifications.notifySuccess( R.string.notification_recording_succeeded, R.drawable.ic_launcher_quick_settings, file, + additionalFiles, ) } - private fun notifyFailure(errorMsg: String?, file: OutputFile?) { + private fun notifyFailure( + errorMsg: String?, + file: OutputFile?, + additionalFiles: List, + ) { notifications.notifyFailure( R.string.notification_recording_failed, R.drawable.ic_launcher_quick_settings, errorMsg, file, + additionalFiles, ) } @@ -444,7 +450,11 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet } } - override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) { + override fun onRecordingCompleted( + thread: RecorderThread, + file: OutputFile?, + additionalFiles: List, + ) { Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}") handler.post { onRecorderExited(thread) @@ -452,17 +462,22 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet // If the recording was initially paused and the user never resumed it, there's no // output file, so nothing needs to be shown. if (file != null) { - notifySuccess(file) + notifySuccess(file, additionalFiles) } } } - override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) { + override fun onRecordingFailed( + thread: RecorderThread, + errorMsg: String?, + file: OutputFile?, + additionalFiles: List, + ) { Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}") handler.post { onRecorderExited(thread) - notifyFailure(errorMsg, file) + notifyFailure(errorMsg, file, additionalFiles) } } } diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index c8b54cdaa..304a5a0e0 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -19,6 +19,8 @@ import com.chiller3.bcr.extension.phoneNumber import com.chiller3.bcr.format.Encoder import com.chiller3.bcr.format.Format import com.chiller3.bcr.format.SampleRate +import com.chiller3.bcr.output.CallMetadata +import com.chiller3.bcr.output.CallMetadataCollector import com.chiller3.bcr.output.DaysRetention import com.chiller3.bcr.output.NoRetention import com.chiller3.bcr.output.OutputDirUtils @@ -113,10 +115,11 @@ class RecorderThread( } // Filename - private val outputFilenameGenerator = OutputFilenameGenerator(context, parentCall) + private val callMetadataCollector = CallMetadataCollector(context, parentCall) + private val outputFilenameGenerator = OutputFilenameGenerator(context) private val dirUtils = OutputDirUtils(context, outputFilenameGenerator.redactor) - val path: OutputPath - get() = outputFilenameGenerator.path + val outputPath: OutputPath + get() = outputFilenameGenerator.generate(callMetadataCollector.callMetadata) // Format private val format: Format @@ -137,7 +140,7 @@ class RecorderThread( } fun onCallDetailsChanged(call: Call, details: Call.Details) { - outputFilenameGenerator.updateCallDetails(call, details) + callMetadataCollector.updateCallDetails(call, details) listener.onRecordingStateChanged(this) } @@ -150,10 +153,10 @@ class RecorderThread( if (parentCall.details.hasProperty(Call.Details.PROPERTY_CONFERENCE)) { for (childCall in parentCall.children) { - childCall.details?.phoneNumber?.let { numbers.add(it) } + childCall.details?.phoneNumber?.let { numbers.add(it.toString()) } } } else { - parentCall.details?.phoneNumber?.let { numbers.add(it) } + parentCall.details?.phoneNumber?.let { numbers.add(it.toString()) } } Log.i(tag, "Evaluating record rules for ${numbers.size} phone number(s)") @@ -174,6 +177,7 @@ class RecorderThread( var success = false var errorMsg: String? = null var resultUri: Uri? = null + val additionalFiles = ArrayList() startLogcat() @@ -188,7 +192,7 @@ class RecorderThread( evaluateRules() - val initialPath = outputFilenameGenerator.path + val initialPath = outputPath val outputFile = dirUtils.createFileInDefaultDir( initialPath.value, format.mimeTypeContainer) resultUri = outputFile.uri @@ -202,7 +206,8 @@ class RecorderThread( state = State.FINALIZING listener.onRecordingStateChanged(this) - val finalPath = outputFilenameGenerator.update(true) + callMetadataCollector.update(true) + val finalPath = outputPath if (keepRecording != false) { dirUtils.tryMoveToOutputDir( @@ -212,6 +217,8 @@ class RecorderThread( )?.let { resultUri = it.uri } + + writeMetadataFile(finalPath.value)?.let { additionalFiles.add(it) } } else { Log.i(tag, "Deleting recording: $finalPath") outputFile.delete() @@ -240,7 +247,7 @@ class RecorderThread( Log.i(tag, "Recording thread completed") try { - stopLogcat() + stopLogcat()?.let { additionalFiles.add(it) } } catch (e: Exception) { Log.w(tag, "Failed to dump logcat", e) } @@ -257,9 +264,9 @@ class RecorderThread( listener.onRecordingStateChanged(this) if (success) { - listener.onRecordingCompleted(this, outputFile) + listener.onRecordingCompleted(this, outputFile, additionalFiles) } else { - listener.onRecordingFailed(this, errorMsg, outputFile) + listener.onRecordingFailed(this, errorMsg, outputFile, additionalFiles) } } } @@ -279,7 +286,7 @@ class RecorderThread( } private fun getLogcatPath(): OutputPath { - return outputFilenameGenerator.path.let { + return outputPath.let { val path = it.value.mapIndexed { i, p -> p + if (i == it.value.size - 1) { ".log" } else { "" } } @@ -298,7 +305,7 @@ class RecorderThread( Log.d(tag, "Starting log file (${BuildConfig.VERSION_NAME})") logcatPath = getLogcatPath() - logcatFile = dirUtils.createFileInDefaultDir(logcatPath.value, "text/plain") + logcatFile = dirUtils.createFileInDefaultDir(logcatPath.value, MIME_LOGCAT) logcatProcess = ProcessBuilder("logcat", "*:V") // This is better than -f because the logcat implementation calls fflush() when the // output stream is stdout. logcatFile is guaranteed to have file:// scheme because it's @@ -308,13 +315,15 @@ class RecorderThread( .start() } - private fun stopLogcat() { + private fun stopLogcat(): OutputFile? { if (!isDebug) { - return + return null } assert(this::logcatProcess.isInitialized) { "logcat not started" } + var uri = logcatFile.uri + try { try { Log.d(tag, "Stopping log file") @@ -329,16 +338,48 @@ class RecorderThread( } } finally { val finalLogcatPath = getLogcatPath() - dirUtils.tryMoveToOutputDir(logcatFile, finalLogcatPath.value, "text/plain") + dirUtils.tryMoveToOutputDir(logcatFile, finalLogcatPath.value, MIME_LOGCAT)?.let { + uri = it.uri + } + } + + return OutputFile(uri, outputFilenameGenerator.redactor.redact(uri), MIME_LOGCAT) + } + + private fun writeMetadataFile(path: List): OutputFile? { + if (!prefs.writeMetadata) { + Log.i(tag, "Metadata writing is disabled") + return null + } + + Log.i(tag, "Writing metadata file") + + try { + val metadataJson = callMetadataCollector.callMetadata.toJson(context) + val metadataBytes = metadataJson.toString(4).toByteArray() + + val metadataFile = dirUtils.createFileInOutputDir(path, MIME_METADATA) + dirUtils.openFile(metadataFile, true).use { + Os.write(it.fileDescriptor, metadataBytes, 0, metadataBytes.size) + } + + return OutputFile( + metadataFile.uri, + outputFilenameGenerator.redactor.redact(metadataFile.uri), + MIME_METADATA, + ) + } catch (e: Exception) { + Log.w(tag, "Failed to write metadata file", e) + return null } } /** * Delete files older than the specified retention period. * - * The "current time" is [OutputFilenameGenerator.callTimestamp], not the actual current time - * and the timestamp of past recordings is based on the filename, not the file modification - * time. Incorrectly-named files are ignored. + * The "current time" is [CallMetadata.timestamp], not the actual current time. The timestamp of + * past recordings is based on the filename, not the file modification time. Incorrectly-named + * files are ignored. */ private fun processRetention() { val directory = prefs.outputDir?.let { @@ -371,7 +412,7 @@ class RecorderThread( continue } - val diff = Duration.between(timestamp, outputFilenameGenerator.callTimestamp) + val diff = Duration.between(timestamp, callMetadataCollector.callMetadata.timestamp) if (diff > retention) { Log.i(tag, "Deleting $redacted ($timestamp)") @@ -546,6 +587,9 @@ class RecorderThread( companion object { private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT + + private const val MIME_LOGCAT = "text/plain" + private const val MIME_METADATA = "application/json" } interface OnRecordingCompletedListener { @@ -558,14 +602,29 @@ class RecorderThread( * Called when the recording completes successfully. [file] is the output file. If [file] is * null, then the recording was started in the paused state and the output file was deleted * because the user never resumed it. + * + * [additionalFiles] are additional files associated with the main output file and should be + * deleted along with the main file. */ - fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) + fun onRecordingCompleted( + thread: RecorderThread, + file: OutputFile?, + additionalFiles: List, + ) /** * Called when an error occurs during recording. If [file] is not null, it points to the * output file containing partially recorded audio. If [file] is null, then either the * output file could not be created or the thread was cancelled before it was started. + * + * [additionalFiles] are additional files associated with the main output file and should be + * deleted along with the main file. */ - fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) + fun onRecordingFailed( + thread: RecorderThread, + errorMsg: String?, + file: OutputFile?, + additionalFiles: List, + ) } } diff --git a/app/src/main/java/com/chiller3/bcr/extension/CallDetailsExtensions.kt b/app/src/main/java/com/chiller3/bcr/extension/CallDetailsExtensions.kt index ab608c2d8..fdb38fbb9 100644 --- a/app/src/main/java/com/chiller3/bcr/extension/CallDetailsExtensions.kt +++ b/app/src/main/java/com/chiller3/bcr/extension/CallDetailsExtensions.kt @@ -1,6 +1,7 @@ package com.chiller3.bcr.extension import android.telecom.Call +import com.chiller3.bcr.output.PhoneNumber -val Call.Details.phoneNumber: String? - get() = handle?.phoneNumber +val Call.Details.phoneNumber: PhoneNumber? + get() = handle?.phoneNumber?.let { PhoneNumber(it) } diff --git a/app/src/main/java/com/chiller3/bcr/output/CallMetadata.kt b/app/src/main/java/com/chiller3/bcr/output/CallMetadata.kt new file mode 100644 index 000000000..c5650fa3e --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/output/CallMetadata.kt @@ -0,0 +1,52 @@ +package com.chiller3.bcr.output + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +enum class CallDirection { + IN, + OUT, + CONFERENCE, + ; + + override fun toString(): String = when (this) { + IN -> "in" + OUT -> "out" + CONFERENCE -> "conference" + } +} + +data class CallPartyDetails( + val phoneNumber: PhoneNumber?, + val callerName: String?, + val contactName: String?, +) { + fun toJson(context: Context) = JSONObject().apply { + put("phone_number", phoneNumber?.toString() ?: JSONObject.NULL) + put("phone_number_formatted", + phoneNumber?.format(context, PhoneNumber.Format.COUNTRY_SPECIFIC) ?: JSONObject.NULL) + put("caller_name", callerName ?: JSONObject.NULL) + put("contact_name", contactName ?: JSONObject.NULL) + } +} + +data class CallMetadata( + val timestamp: ZonedDateTime, + val direction: CallDirection?, + val simCount: Int?, + val simSlot: Int?, + val callLogName: String?, + val calls: List, +) { + fun toJson(context: Context) = JSONObject().apply { + put("timestamp_unix_ms", timestamp.toInstant().toEpochMilli()) + put("timestamp", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(timestamp)) + put("direction", direction?.toString() ?: JSONObject.NULL) + put("sim_slot", simSlot ?: JSONObject.NULL) + put("call_log_name", callLogName ?: JSONObject.NULL) + put("calls", JSONArray(calls.map { it.toJson(context) })) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt b/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt new file mode 100644 index 000000000..32313b34d --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/output/CallMetadataCollector.kt @@ -0,0 +1,263 @@ +package com.chiller3.bcr.output + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.provider.CallLog +import android.telecom.Call +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Log +import androidx.core.database.getStringOrNull +import com.chiller3.bcr.extension.phoneNumber +import com.chiller3.bcr.findContactsByPhoneNumber +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +class CallMetadataCollector( + private val context: Context, + private val parentCall: Call, +) { + // Call information + private val callDetails = mutableMapOf() + private val isConference = parentCall.details.hasProperty(Call.Details.PROPERTY_CONFERENCE) + private lateinit var _callMetadata: CallMetadata + val callMetadata: CallMetadata + get() = synchronized(this) { + _callMetadata + } + + init { + callDetails[parentCall] = parentCall.details + if (isConference) { + for (childCall in parentCall.children) { + callDetails[childCall] = childCall.details + } + } + + update(false) + } + + private fun getContactDisplayName(details: Call.Details, allowManualLookup: Boolean): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val name = details.contactDisplayName + if (name != null) { + return name + } + } + + // In conference calls, the telephony framework sometimes doesn't return the contact display + // name for every party in the call, so do the lookup ourselves. This is similar to what + // InCallUI does, except it doesn't even try to look at contactDisplayName. + if (isConference) { + Log.w(TAG, "Contact display name missing in conference child call") + } + + // This is disabled until the very last filename update because it's synchronous. + if (!allowManualLookup) { + Log.d(TAG, "Manual contact lookup is disabled for this invocation") + return null + } + + if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != + PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "Permissions not granted for looking up contacts") + return null + } + + Log.d(TAG, "Performing manual contact lookup") + + val number = details.phoneNumber + if (number == null) { + Log.w(TAG, "Cannot determine phone number from call") + return null + } + + for (contact in findContactsByPhoneNumber(context, number.toString())) { + Log.d(TAG, "Found contact display name via manual lookup") + return contact.displayName + } + + Log.d(TAG, "Contact not found via manual lookup") + return null + } + + private fun getCallLogCachedName( + parentDetails: Call.Details, + allowBlockingCalls: Boolean, + ): String? { + // This is disabled until the very last filename update because it's synchronous. + if (!allowBlockingCalls) { + Log.d(TAG, "Call log lookup is disabled for this invocation") + return null + } + + if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != + PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "Permissions not granted for looking up call log") + return null + } + + // The call log does not show all participants in a conference call + if (isConference) { + Log.w(TAG, "Skipping call log lookup due to conference call") + return null + } + + val uri = CallLog.Calls.CONTENT_URI.buildUpon() + .appendQueryParameter(CallLog.Calls.LIMIT_PARAM_KEY, "1") + .build() + + // System.nanoTime() is more likely to be monotonic than Instant.now() + val start = System.nanoTime() + var attempt = 1 + + while (true) { + val now = System.nanoTime() + if (now >= start + CALL_LOG_QUERY_TIMEOUT_NANOS) { + break + } + + val prefix = "[Attempt #$attempt @ ${(now - start) / 1_000_000}ms] " + + context.contentResolver.query( + uri, + arrayOf(CallLog.Calls.CACHED_NAME), + "${CallLog.Calls.DATE} = ?", + arrayOf(parentDetails.creationTimeMillis.toString()), + "${CallLog.Calls._ID} DESC", + )?.use { cursor -> + if (cursor.moveToFirst()) { + Log.d(TAG, "${prefix}Found call log entry") + + val index = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) + if (index != -1) { + val name = cursor.getStringOrNull(index) + if (name != null) { + Log.d(TAG, "${prefix}Found call log cached name") + return name + } + } + + Log.d(TAG, "${prefix}Call log entry has no cached name") + } else { + Log.d(TAG, "${prefix}Call log entry not found") + } + } + + attempt += 1 + Thread.sleep(CALL_LOG_QUERY_RETRY_DELAY_MILLIS) + } + + Log.d(TAG, "Call log cached name not found after all ${attempt - 1} attempts") + return null + } + + private fun computeMetadata( + parentDetails: Call.Details, + displayDetails: List, + allowBlockingCalls: Boolean, + ): CallMetadata { + val instant = Instant.ofEpochMilli(parentDetails.creationTimeMillis) + val timestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()) + + // AOSP's telephony framework has internal documentation that specifies that the call + // direction is meaningless for conference calls until enough participants hang up that it + // becomes an emulated one-on-one call. + val direction = if (isConference) { + CallDirection.CONFERENCE + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + when (parentDetails.callDirection) { + Call.Details.DIRECTION_INCOMING -> CallDirection.IN + Call.Details.DIRECTION_OUTGOING -> CallDirection.OUT + Call.Details.DIRECTION_UNKNOWN -> null + else -> null + } + } else { + null + } + + var simCount: Int? = null + var simSlot: Int? = null + + 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) + + val telephonyManager = context.getSystemService(TelephonyManager::class.java) + val subscriptionId = telephonyManager.getSubscriptionId(parentDetails.accountHandle) + val subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId) + + simCount = subscriptionManager.activeSubscriptionInfoCount + simSlot = subscriptionInfo.simSlotIndex + 1 + } + + val callLogName = getCallLogCachedName(parentDetails, allowBlockingCalls) + + val calls = displayDetails.map { + CallPartyDetails( + it.phoneNumber, + it.callerDisplayName, + getContactDisplayName(it, allowBlockingCalls), + ) + } + + return CallMetadata( + timestamp, + direction, + simCount, + simSlot, + callLogName, + calls, + ) + } + + fun update(allowBlockingCalls: Boolean): CallMetadata { + val parentDetails = callDetails[parentCall]!! + val displayDetails = if (isConference) { + callDetails.entries.asSequence() + .filter { it.key != parentCall } + .map { it.value } + .toList() + } else { + listOf(parentDetails) + } + + val metadata = computeMetadata(parentDetails, displayDetails, allowBlockingCalls) + synchronized(this) { + _callMetadata = metadata + } + + return metadata + } + + /** + * Update state with information from [details]. + * + * @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 updateCallDetails(call: Call, details: Call.Details): CallMetadata { + if (call !== parentCall && call.parent !== parentCall) { + throw IllegalStateException("Not the parent call nor one of its children: $call") + } + + synchronized(this) { + callDetails[call] = details + + return update(false) + } + } + + companion object { + private val TAG = CallMetadataCollector::class.java.simpleName + + private const val CALL_LOG_QUERY_TIMEOUT_NANOS = 2_000_000_000L + private const val CALL_LOG_QUERY_RETRY_DELAY_MILLIS = 100L + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt b/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt index 30783c9a3..4125ddc58 100644 --- a/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt +++ b/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt @@ -48,6 +48,24 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto ?: throw IOException("Failed to create file $redactedPath in $redactedRoot") } + private fun createFileWithFallback( + root: DocumentFile, + path: List, + mimeType: String, + ): DocumentFile { + return try { + createFile(root, path, mimeType) + } catch (e: Exception) { + Log.w(TAG, "Failed to create file; using fallback path", e) + + // If the failure is due to eg. running out of space, then there's nothing we can do and + // this will just fail the same way again. However, if the failure is due to eg. an + // intermediate path segment being a file instead of a directory, then at least the user + // will still have the recording, even if the directory structure is wrong. + createFile(root, getErrorFallbackPath(path), mimeType) + } + } + /** * Open seekable file descriptor to [file]. * @@ -156,17 +174,24 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto fun createFileInDefaultDir(path: List, mimeType: String): DocumentFile { val defaultDir = DocumentFile.fromFile(prefs.defaultOutputDir) - return try { - createFile(defaultDir, path, mimeType) - } catch (e: Exception) { - Log.w(TAG, "Failed to create file; using fallback path", e) + return createFileWithFallback(defaultDir, path, mimeType) + } - // If the failure is due to eg. running out of space, then there's nothing we can do and - // this will just fail the same way again. However, if the failure is due to eg. an - // intermediate path segment being a file instead of a directory, then at least the user - // will still have the recording, even if the directory structure is wrong. - createFile(defaultDir, getErrorFallbackPath(path), mimeType) - } + /** + * Create [path] in the output directory. + * + * @param path The last element is the filename, which should not contain a file extension + * @param mimeType Determines the file extension + * + * @throws IOException if the file could not be created in the output directory + */ + fun createFileInOutputDir(path: List, mimeType: String): DocumentFile { + val userDir = prefs.outputDir?.let { + // Only returns null on API <21 + DocumentFile.fromTreeUri(context, it)!! + } ?: DocumentFile.fromFile(prefs.defaultOutputDir) + + return createFileWithFallback(userDir, path, mimeType) } /** diff --git a/app/src/main/java/com/chiller3/bcr/output/OutputFile.kt b/app/src/main/java/com/chiller3/bcr/output/OutputFile.kt index 092d37e38..8300a31fe 100644 --- a/app/src/main/java/com/chiller3/bcr/output/OutputFile.kt +++ b/app/src/main/java/com/chiller3/bcr/output/OutputFile.kt @@ -3,9 +3,12 @@ package com.chiller3.bcr.output import android.content.ContentResolver import android.content.Context import android.net.Uri +import android.os.Parcelable import androidx.core.net.toFile import androidx.documentfile.provider.DocumentFile +import kotlinx.parcelize.Parcelize +@Parcelize data class OutputFile( /** * URI to a single file, which may have a [ContentResolver.SCHEME_FILE] or @@ -18,7 +21,7 @@ data class OutputFile( /** MIME type of [uri]'s contents. */ val mimeType: String, -) { +) : Parcelable { fun toDocumentFile(context: Context): DocumentFile = when (uri.scheme) { ContentResolver.SCHEME_FILE -> DocumentFile.fromFile(uri.toFile()) diff --git a/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt b/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt index a1521e6b5..ebb4dec53 100644 --- a/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt +++ b/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt @@ -1,33 +1,19 @@ package com.chiller3.bcr.output -import android.Manifest import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import android.provider.CallLog -import android.telecom.Call -import android.telephony.PhoneNumberUtils -import android.telephony.SubscriptionManager -import android.telephony.TelephonyManager import android.util.Log -import androidx.core.database.getStringOrNull import com.chiller3.bcr.Preferences -import com.chiller3.bcr.extension.phoneNumber -import com.chiller3.bcr.findContactsByPhoneNumber import com.chiller3.bcr.template.Template import java.text.ParsePosition import java.time.DateTimeException -import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime -import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeParseException import java.time.format.SignStyle import java.time.temporal.ChronoField import java.time.temporal.Temporal -import java.util.Locale data class OutputPath( val value: List, @@ -43,23 +29,13 @@ data class OutputPath( */ class OutputFilenameGenerator( private val context: Context, - private val parentCall: Call, ) { // Templates private val filenameTemplate = Preferences(context).filenameTemplate ?: Preferences.DEFAULT_FILENAME_TEMPLATE private val dateVarLocations = filenameTemplate.findVariableRef(DATE_VAR)?.second - // Call information - private val callDetails = mutableMapOf() - private val isConference = parentCall.details.hasProperty(Call.Details.PROPERTY_CONFERENCE) - // Timestamps - private lateinit var _callTimestamp: ZonedDateTime - val callTimestamp: ZonedDateTime - get() = synchronized(this) { - _callTimestamp - } private var formatter = FORMATTER // Redactions @@ -79,41 +55,8 @@ class OutputFilenameGenerator( } } - private lateinit var _path: OutputPath - val path: OutputPath - get() = synchronized(this) { - _path - } - init { Log.i(TAG, "Filename template: $filenameTemplate") - - callDetails[parentCall] = parentCall.details - if (isConference) { - for (childCall in parentCall.children) { - callDetails[childCall] = childCall.details - } - } - - update(false) - } - - /** - * Update [path] with information from [details]. - * - * @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 updateCallDetails(call: Call, details: Call.Details): OutputPath { - if (call !== parentCall && call.parent !== parentCall) { - throw IllegalStateException("Not the parent call nor one of its children: $call") - } - - synchronized(this) { - callDetails[call] = details - - return update(false) - } } private fun addRedaction(source: String, target: String) { @@ -129,284 +72,86 @@ class OutputFilenameGenerator( } } - /** - * Get the current ISO country code for phone number formatting. - */ - private fun getIsoCountryCode(): String? { - val telephonyManager = context.getSystemService(TelephonyManager::class.java) - var result: String? = null - - if (telephonyManager.phoneType == TelephonyManager.PHONE_TYPE_GSM) { - result = telephonyManager.networkCountryIso - } - if (result.isNullOrEmpty()) { - result = telephonyManager.simCountryIso - } - if (result.isNullOrEmpty()) { - result = Locale.getDefault().country - } - if (result.isNullOrEmpty()) { - return null - } - return result.uppercase() - } - - private fun getPhoneNumber(details: Call.Details, arg: String?): String? { - val number = details.phoneNumber ?: return null - - when (arg) { - null, "E.164" -> { - // Default is already E.164 - return number - } - "digits_only" -> { - return number.filter { Character.digit(it, 10) != -1 } - } - "formatted" -> { - val country = getIsoCountryCode() - if (country == null) { - Log.w(TAG, "Failed to detect country") - return number - } - - val formatted = PhoneNumberUtils.formatNumber(number, country) - if (formatted == null) { - Log.w(TAG, "Phone number cannot be formatted for country $country") - // Don't fail since this isn't the user's fault - return number - } - - return formatted - } + private fun formatPhoneNumber(number: PhoneNumber, arg: String?): String? { + return when (arg) { + // Default is already E.164 + null, "E.164" -> number.toString() + "digits_only" -> number.format(context, PhoneNumber.Format.DIGITS_ONLY) + "formatted" -> number.format(context, PhoneNumber.Format.COUNTRY_SPECIFIC) + // Don't fail since this isn't the user's fault + ?: number.toString() else -> { Log.w(TAG, "Unknown phone_number format arg: $arg") - return null + null } } } - private fun getContactDisplayName(details: Call.Details, allowManualLookup: Boolean): String? { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val name = details.contactDisplayName - if (name != null) { - return name - } - } - - // In conference calls, the telephony framework sometimes doesn't return the contact display - // name for every party in the call, so do the lookup ourselves. This is similar to what - // InCallUI does, except it doesn't even try to look at contactDisplayName. - if (isConference) { - Log.w(TAG, "Contact display name missing in conference child call") - } - - // This is disabled until the very last filename update because it's synchronous. - if (!allowManualLookup) { - Log.d(TAG, "Manual contact lookup is disabled for this invocation") - return null - } - - if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != - PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "Permissions not granted for looking up contacts") - return null - } - - Log.d(TAG, "Performing manual contact lookup") - - val number = getPhoneNumber(details, null) - if (number == null) { - Log.w(TAG, "Cannot determine phone number from call") - return null - } - - for (contact in findContactsByPhoneNumber(context, number)) { - Log.d(TAG, "Found contact display name via manual lookup") - return contact.displayName - } - - Log.d(TAG, "Contact not found via manual lookup") - return null - } - - private fun getCallLogCachedName( - parentDetails: Call.Details, - allowBlockingCalls: Boolean, - ): String? { - // This is disabled until the very last filename update because it's synchronous. - if (!allowBlockingCalls) { - Log.d(TAG, "Call log lookup is disabled for this invocation") - return null - } - - if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != - PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "Permissions not granted for looking up call log") - return null - } - - // The call log does not show all participants in a conference call - if (isConference) { - Log.w(TAG, "Skipping call log lookup due to conference call") - return null - } - - val uri = CallLog.Calls.CONTENT_URI.buildUpon() - .appendQueryParameter(CallLog.Calls.LIMIT_PARAM_KEY, "1") - .build() - - // System.nanoTime() is more likely to be monotonic than Instant.now() - val start = System.nanoTime() - var attempt = 1 - - while (true) { - val now = System.nanoTime() - if (now >= start + CALL_LOG_QUERY_TIMEOUT_NANOS) { - break - } - - val prefix = "[Attempt #$attempt @ ${(now - start) / 1_000_000}ms] " - - context.contentResolver.query( - uri, - arrayOf(CallLog.Calls.CACHED_NAME), - "${CallLog.Calls.DATE} = ?", - arrayOf(parentDetails.creationTimeMillis.toString()), - "${CallLog.Calls._ID} DESC", - )?.use { cursor -> - if (cursor.moveToFirst()) { - Log.d(TAG, "${prefix}Found call log entry") - - val index = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) - if (index != -1) { - val name = cursor.getStringOrNull(index) - if (name != null) { - Log.d(TAG, "${prefix}Found call log cached name") - return name - } - } - - Log.d(TAG, "${prefix}Call log entry has no cached name") - } else { - Log.d(TAG, "${prefix}Call log entry not found") - } - } - - attempt += 1 - Thread.sleep(CALL_LOG_QUERY_RETRY_DELAY_MILLIS) - } - - Log.d(TAG, "Call log cached name not found after all ${attempt - 1} attempts") - return null - } - private fun evaluateVars( name: String, arg: String?, - parentDetails: Call.Details, - displayDetails: List, - allowBlockingCalls: Boolean, + metadata: CallMetadata, ): String? { when (name) { "date" -> { - val instant = Instant.ofEpochMilli(parentDetails.creationTimeMillis) - _callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()) - if (arg != null) { Log.d(TAG, "Using custom datetime pattern: $arg") - try { - formatter = DateTimeFormatterBuilder() - .appendPattern(arg) - .toFormatter() - } catch (e: Exception) { - Log.w(TAG, "Invalid custom datetime pattern: $arg; using default", e) + synchronized(this) { + try { + formatter = DateTimeFormatterBuilder() + .appendPattern(arg) + .toFormatter() + } catch (e: Exception) { + Log.w(TAG, "Invalid custom datetime pattern: $arg; using default", e) + } } } - return formatter.format(_callTimestamp) - } - "direction" -> { - // AOSP's telephony framework has internal documentation that specifies that the - // call direction is meaningless for conference calls until enough participants hang - // up that it becomes an emulated one-on-one call. - if (isConference) { - return "conference" - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - when (parentDetails.callDirection) { - Call.Details.DIRECTION_INCOMING -> return "in" - Call.Details.DIRECTION_OUTGOING -> return "out" - Call.Details.DIRECTION_UNKNOWN -> {} - } - } + return formatter.format(metadata.timestamp) } + "direction" -> metadata.direction?.toString() "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(parentDetails.accountHandle) - val subscriptionInfo = subscriptionManager.getActiveSubscriptionInfo(subscriptionId) - - return "${subscriptionInfo.simSlotIndex + 1}" - } + // Only append SIM slot ID if the device has multiple active SIMs + if (metadata.simCount != null && metadata.simCount > 1) { + return metadata.simSlot?.toString() } } "phone_number" -> { - val joined = displayDetails.asSequence() - .map { d -> getPhoneNumber(d, arg) } + val joined = metadata.calls.asSequence() + .map { it.phoneNumber?.let { number -> formatPhoneNumber(number, arg) } } .filterNotNull() .joinToString(",") if (joined.isNotEmpty()) { - addRedaction(joined, if (isConference) { - "" - } else { - "" - }) + addRedaction(joined, "") return joined } } "caller_name" -> { - val joined = displayDetails.asSequence() - .map { d -> d.callerDisplayName?.trim() } + val joined = metadata.calls.asSequence() + .map { it.callerName?.trim() } .filter { n -> !n.isNullOrEmpty() } .joinToString(",") if (joined.isNotEmpty()) { - addRedaction(joined, if (isConference) { - "" - } else { - "" - }) + addRedaction(joined, "") return joined } } "contact_name" -> { - val joined = displayDetails.asSequence() - .map { d -> getContactDisplayName(d, allowBlockingCalls)?.trim() } + val joined = metadata.calls.asSequence() + .map { it.contactName?.trim() } .filter { n -> !n.isNullOrEmpty() } .joinToString(",") if (joined.isNotEmpty()) { - addRedaction(joined, if (isConference) { - "" - } else { - "" - }) + addRedaction(joined, "") return joined } } "call_log_name" -> { - val cachedName = getCallLogCachedName(parentDetails, allowBlockingCalls)?.trim() + val cachedName = metadata.callLogName?.trim() if (!cachedName.isNullOrEmpty()) { addRedaction(cachedName, "") return cachedName @@ -420,57 +165,44 @@ class OutputFilenameGenerator( return null } - private fun generate(template: Template, allowBlockingCalls: Boolean): OutputPath { - synchronized(this) { - val parentDetails = callDetails[parentCall]!! - val displayDetails = if (isConference) { - callDetails.entries.asSequence() - .filter { it.key != parentCall } - .map { it.value } - .toList() - } else { - listOf(parentDetails) - } - - val newPathString = template.evaluate { name, arg -> - val result = evaluateVars( - name, arg, parentDetails, displayDetails, allowBlockingCalls)?.trim() + private fun generate(template: Template, metadata: CallMetadata): OutputPath { + val newPathString = template.evaluate { name, arg -> + val result = evaluateVars(name, arg, metadata)?.trim() - // Directories are allowed in the template, but not in a variable's value unless - // it's part of the timestamp because that's fully user controlled. - when (name) { - DATE_VAR -> result - else -> result?.replace('/', '_') - } + // Directories are allowed in the template, but not in a variable's value unless it's + // part of the timestamp because that's fully user controlled. + when (name) { + DATE_VAR -> result + else -> result?.replace('/', '_') } - val newPath = splitPath(newPathString) - - return OutputPath(newPath, redactor.redact(newPath)) } + val newPath = splitPath(newPathString) + + return OutputPath(newPath, redactor.redact(newPath)) } - fun update(allowBlockingCalls: Boolean): OutputPath { - synchronized(this) { - _path = try { - generate(filenameTemplate, allowBlockingCalls) - } catch (e: Exception) { - if (filenameTemplate === Preferences.DEFAULT_FILENAME_TEMPLATE) { - throw e - } else { - Log.w(TAG, "Failed to evaluate custom template: $filenameTemplate", e) - generate(Preferences.DEFAULT_FILENAME_TEMPLATE, allowBlockingCalls) - } + fun generate(metadata: CallMetadata): OutputPath { + val path = try { + generate(filenameTemplate, metadata) + } catch (e: Exception) { + if (filenameTemplate === Preferences.DEFAULT_FILENAME_TEMPLATE) { + throw e + } else { + Log.w(TAG, "Failed to evaluate custom template: $filenameTemplate", e) + generate(Preferences.DEFAULT_FILENAME_TEMPLATE, metadata) } + } - Log.i(TAG, "Updated filename: $_path") + Log.i(TAG, "Generated filename: $path") - return _path - } + return path } private fun parseTimestamp(input: String, startPos: Int): Temporal? { val pos = ParsePosition(startPos) - val parsed = formatter.parse(input, pos) + val parsed = synchronized(this) { + formatter.parse(input, pos) + } return try { parsed.query(ZonedDateTime::from) @@ -579,9 +311,6 @@ class OutputFilenameGenerator( .appendOffset("+HHMMss", "+0000") .toFormatter() - private const val CALL_LOG_QUERY_TIMEOUT_NANOS = 2_000_000_000L - private const val CALL_LOG_QUERY_RETRY_DELAY_MILLIS = 100L - private fun splitPath(pathString: String) = pathString .splitToSequence('/') .filter { it.isNotEmpty() && it != "." && it != ".." } diff --git a/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt b/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt new file mode 100644 index 000000000..cd7550242 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/output/PhoneNumber.kt @@ -0,0 +1,61 @@ +package com.chiller3.bcr.output + +import android.content.Context +import android.telephony.PhoneNumberUtils +import android.telephony.TelephonyManager +import android.util.Log +import java.util.Locale + +class PhoneNumber(private val number: String) { + fun format(context: Context, format: Format) = when (format) { + Format.DIGITS_ONLY -> number.filter { Character.digit(it, 10) != -1 } + Format.COUNTRY_SPECIFIC -> { + val country = getIsoCountryCode(context) + if (country == null) { + Log.w(TAG, "Failed to detect country") + null + } else { + val formatted = PhoneNumberUtils.formatNumber(number, country) + if (formatted == null) { + Log.w(TAG, "Phone number cannot be formatted for country $country") + null + } else { + formatted + } + } + } + } + + override fun toString(): String = number + + companion object { + private val TAG = PhoneNumber::class.java.simpleName + + /** + * Get the current ISO country code for phone number formatting. + */ + private fun getIsoCountryCode(context: Context): String? { + val telephonyManager = context.getSystemService(TelephonyManager::class.java) + var result: String? = null + + if (telephonyManager.phoneType == TelephonyManager.PHONE_TYPE_GSM) { + result = telephonyManager.networkCountryIso + } + if (result.isNullOrEmpty()) { + result = telephonyManager.simCountryIso + } + if (result.isNullOrEmpty()) { + result = Locale.getDefault().country + } + if (result.isNullOrEmpty()) { + return null + } + return result.uppercase() + } + } + + enum class Format { + DIGITS_ONLY, + COUNTRY_SPECIFIC, + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index beb744623..d46fcf538 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,9 @@ Disable battery optimization Reduces the chance of the app being killed by the system. + Write metadata file + Create a JSON file containing details about the call next to the audio file. + Version diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index c9ac7ad92..eeeacea81 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -35,6 +35,12 @@ app:title="@string/pref_inhibit_batt_opt_name" app:summary="@string/pref_inhibit_batt_opt_desc" app:iconSpaceReserved="false" /> + + parse( diff --git a/build.gradle.kts b/build.gradle.kts index 0c3932fca..9e4963797 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") version "8.0.2" apply false id("org.jetbrains.kotlin.android") version "1.9.0" apply false + id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.0" apply false } task("clean") { diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 4c828fe12..cc65c1d19 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -936,6 +936,14 @@ + + + + + + + + @@ -1016,6 +1024,14 @@ + + + + + + + + @@ -1024,6 +1040,14 @@ + + + + + + + + @@ -1032,6 +1056,14 @@ + + + + + + + + @@ -1040,6 +1072,14 @@ + + + + + + + + @@ -1048,6 +1088,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1056,6 +1120,14 @@ + + + + + + + + @@ -1064,6 +1136,14 @@ + + + + + + + + @@ -1325,6 +1405,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1333,6 +1445,14 @@ + + + + + + + + @@ -2051,6 +2171,14 @@ + + + + + + + + @@ -2182,6 +2310,22 @@ + + + + + + + + + + + + + + + + @@ -2379,6 +2523,11 @@ + + + + + @@ -2395,11 +2544,21 @@ + + + + + + + + + + @@ -2416,6 +2575,14 @@ + + + + + + + +