Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for writing call metadata to an adjacent JSON file #382

Merged
merged 1 commit into from
Jul 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<format string>}`. 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 (`+<country code><subscriber>`).
* `{phone_number:digits_only}`: Phone number with digits only (no `+` or separators).
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 34 additions & 29 deletions app/src/main/java/com/chiller3/bcr/NotificationActionService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<OutputFile>,
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<OutputFile, Int> {
val file = parseFileFromIntent(intent)
private fun parseDeleteUriIntent(intent: Intent): Pair<List<OutputFile>, 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
Expand All @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/com/chiller3/bcr/Notifications.kt
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ class Notifications(
@DrawableRes icon: Int,
errorMsg: String?,
file: OutputFile?,
additionalFiles: List<OutputFile>,
) {
val notificationId = allocateNotificationId()

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -296,8 +301,9 @@ class Notifications(
@StringRes title: Int,
@DrawableRes icon: Int,
file: OutputFile,
additionalFiles: List<OutputFile>,
) {
sendAlertNotification(CHANNEL_ID_SUCCESS, title, icon, null, file)
sendAlertNotification(CHANNEL_ID_SUCCESS, title, icon, null, file, additionalFiles)
vibrateIfEnabled(CHANNEL_ID_SUCCESS)
}

Expand All @@ -313,8 +319,9 @@ class Notifications(
@DrawableRes icon: Int,
errorMsg: String?,
file: OutputFile?,
additionalFiles: List<OutputFile>,
) {
sendAlertNotification(CHANNEL_ID_FAILURE, title, icon, errorMsg, file)
sendAlertNotification(CHANNEL_ID_FAILURE, title, icon, errorMsg, file, additionalFiles)
vibrateIfEnabled(CHANNEL_ID_FAILURE)
}

Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) }
}
31 changes: 23 additions & 8 deletions app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -404,20 +404,26 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
}
}

private fun notifySuccess(file: OutputFile) {
private fun notifySuccess(file: OutputFile, additionalFiles: List<OutputFile>) {
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<OutputFile>,
) {
notifications.notifyFailure(
R.string.notification_recording_failed,
R.drawable.ic_launcher_quick_settings,
errorMsg,
file,
additionalFiles,
)
}

Expand All @@ -444,25 +450,34 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
}
}

override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) {
override fun onRecordingCompleted(
thread: RecorderThread,
file: OutputFile?,
additionalFiles: List<OutputFile>,
) {
Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}")
handler.post {
onRecorderExited(thread)

// 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<OutputFile>,
) {
Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}")
handler.post {
onRecorderExited(thread)

notifyFailure(errorMsg, file)
notifyFailure(errorMsg, file, additionalFiles)
}
}
}
Loading
Loading