Skip to content

Commit e5fe6ce

Browse files
committed
Add support for specifying a minimum recording duration
If the recording isn't long enough, it will be discarded at the end of the call. The duration is computed based on the recording duration (number of encoded frames), not the call duration, so it excludes regions where the user explicitly paused the recording and regions where the call was placed on hold. Similar to the record rules, this can be overridden during the call from BCR's notification. Fixes: #411 Fixes: #604 Signed-off-by: Andrew Gunnerson <[email protected]>
1 parent 0e9042c commit e5fe6ce

File tree

7 files changed

+236
-16
lines changed

7 files changed

+236
-16
lines changed

app/src/main/java/com/chiller3/bcr/Preferences.kt

+9
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Preferences(initialContext: Context) {
2929
const val PREF_OUTPUT_DIR = "output_dir"
3030
const val PREF_FILENAME_TEMPLATE = "filename_template"
3131
const val PREF_OUTPUT_FORMAT = "output_format"
32+
const val PREF_MIN_DURATION = "min_duration"
3233
const val PREF_INHIBIT_BATT_OPT = "inhibit_batt_opt"
3334
private const val PREF_WRITE_METADATA = "write_metadata"
3435
private const val PREF_RECORD_TELECOM_APPS = "record_telecom_apps"
@@ -384,6 +385,14 @@ class Preferences(initialContext: Context) {
384385
}
385386
}
386387

388+
/**
389+
* Minimum recording duration for it to be kept if the record rules would have allowed it to be
390+
* kept in the first place.
391+
*/
392+
var minDuration: Int
393+
get() = prefs.getInt(PREF_MIN_DURATION, 0)
394+
set(seconds) = prefs.edit { putInt(PREF_MIN_DURATION, seconds) }
395+
387396
/**
388397
* Whether to write call metadata file.
389398
*/

app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt

+28-9
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,11 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
142142
}
143143
ACTION_RESTORE, ACTION_DELETE -> {
144144
notificationIdsToRecorders[notificationId]?.keepRecording =
145-
action == ACTION_RESTORE
145+
if (action == ACTION_RESTORE) {
146+
RecorderThread.KeepState.KEEP
147+
} else {
148+
RecorderThread.KeepState.DISCARD
149+
}
146150
}
147151
else -> throw IllegalArgumentException("Invalid action: $action")
148152
}
@@ -369,14 +373,29 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet
369373

370374
if (canShowDelete) {
371375
recorder.keepRecording?.let {
372-
if (it) {
373-
actionResIds.add(R.string.notification_action_delete)
374-
actionIntents.add(createActionIntent(notificationId, ACTION_DELETE))
375-
} else {
376-
message.append("\n\n")
377-
message.append(getString(R.string.notification_message_delete_at_end))
378-
actionResIds.add(R.string.notification_action_restore)
379-
actionIntents.add(createActionIntent(notificationId, ACTION_RESTORE))
376+
when (it) {
377+
RecorderThread.KeepState.KEEP -> {
378+
actionResIds.add(R.string.notification_action_delete)
379+
actionIntents.add(createActionIntent(notificationId, ACTION_DELETE))
380+
}
381+
RecorderThread.KeepState.DISCARD -> {
382+
message.append("\n\n")
383+
message.append(getString(R.string.notification_message_delete_at_end))
384+
actionResIds.add(R.string.notification_action_restore)
385+
actionIntents.add(createActionIntent(notificationId, ACTION_RESTORE))
386+
}
387+
RecorderThread.KeepState.DISCARD_TOO_SHORT -> {
388+
val minDuration = prefs.minDuration
389+
390+
message.append("\n\n")
391+
message.append(resources.getQuantityString(
392+
R.plurals.notification_message_delete_at_end_too_short,
393+
minDuration,
394+
minDuration,
395+
))
396+
actionResIds.add(R.string.notification_action_restore)
397+
actionIntents.add(createActionIntent(notificationId, ACTION_RESTORE))
398+
}
380399
}
381400
}
382401
}

app/src/main/java/com/chiller3/bcr/RecorderThread.kt

+45-4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import org.json.JSONObject
3636
import java.lang.Process
3737
import java.nio.ByteBuffer
3838
import java.time.Duration
39+
import java.util.concurrent.atomic.AtomicReference
3940
import android.os.Process as AndroidProcess
4041

4142
/**
@@ -72,22 +73,40 @@ class RecorderThread(
7273
private set
7374
@Volatile private var isCancelled = false
7475

76+
enum class KeepState {
77+
KEEP,
78+
DISCARD,
79+
DISCARD_TOO_SHORT,
80+
}
81+
7582
/**
7683
* Whether to preserve the recording.
7784
*
7885
* This is initially set to null while the [RecordRule]s are being processed. Once computed,
7986
* this field is set to the computed value. The value can be changed, including from other
8087
* threads, in case the user wants to override the rules during the middle of the call.
8188
*/
82-
@Volatile var keepRecording: Boolean? = null
89+
private val _keepRecording = AtomicReference<KeepState>()
90+
var keepRecording: KeepState?
91+
get() = _keepRecording.get()
8392
set(value) {
8493
require(value != null)
8594

86-
field = value
95+
_keepRecording.set(value)
96+
Log.d(tag, "Keep state updated: $value")
97+
98+
listener.onRecordingStateChanged(this)
99+
}
100+
101+
private fun keepRecordingCompareAndSet(expected: KeepState?, value: KeepState?) {
102+
require(value != null)
103+
104+
if (_keepRecording.compareAndSet(expected, value)) {
87105
Log.d(tag, "Keep state updated: $value")
88106

89107
listener.onRecordingStateChanged(this)
90108
}
109+
}
91110

92111
/**
93112
* Whether the user paused the recording.
@@ -123,6 +142,8 @@ class RecorderThread(
123142
val outputPath: OutputPath
124143
get() = outputFilenameGenerator.generate(callMetadataCollector.callMetadata)
125144

145+
private val minDuration: Int
146+
126147
// Format
127148
private val format: Format
128149
private val formatParam: UInt?
@@ -136,6 +157,8 @@ class RecorderThread(
136157
init {
137158
Log.i(tag, "Created thread for call: $parentCall")
138159

160+
minDuration = prefs.minDuration
161+
139162
val savedFormat = Format.fromPreferences(prefs)
140163
format = savedFormat.first
141164
formatParam = savedFormat.second
@@ -165,14 +188,27 @@ class RecorderThread(
165188
Log.i(tag, "Evaluating record rules for ${numbers.size} phone number(s)")
166189

167190
val rules = prefs.recordRules ?: Preferences.DEFAULT_RECORD_RULES
168-
keepRecording = try {
191+
val keep = try {
169192
RecordRule.evaluate(context, rules, numbers)
170193
} catch (e: Exception) {
171194
Log.w(tag, "Failed to evaluate record rules", e)
172195
// Err on the side of caution
173196
true
174197
}
175198

199+
keepRecordingCompareAndSet(
200+
null,
201+
if (keep) {
202+
if (minDuration > 0) {
203+
KeepState.DISCARD_TOO_SHORT
204+
} else {
205+
KeepState.KEEP
206+
}
207+
} else {
208+
KeepState.DISCARD
209+
},
210+
)
211+
176212
listener.onRecordingStateChanged(this)
177213
}
178214

@@ -214,7 +250,7 @@ class RecorderThread(
214250
callMetadataCollector.update(true)
215251
val finalPath = outputPath
216252

217-
if (keepRecording != false) {
253+
if (keepRecording == KeepState.KEEP) {
218254
dirUtils.tryMoveToOutputDir(
219255
outputDocFile,
220256
finalPath.value,
@@ -646,6 +682,11 @@ class RecorderThread(
646682
"record=${recordElapsed / 1_000_000.0}ms, " +
647683
"encode=${encodeElapsed / 1_000_000.0}ms")
648684
}
685+
686+
val secondsEncoded = numFramesEncoded / audioRecord.sampleRate / audioRecord.channelCount
687+
if (secondsEncoded >= minDuration) {
688+
keepRecordingCompareAndSet(KeepState.DISCARD_TOO_SHORT, KeepState.KEEP)
689+
}
649690
}
650691

651692
if (wasReadSamplesError) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.chiller3.bcr.dialog
2+
3+
import android.app.Dialog
4+
import android.content.DialogInterface
5+
import android.os.Bundle
6+
import android.text.InputType
7+
import androidx.appcompat.app.AlertDialog
8+
import androidx.core.os.bundleOf
9+
import androidx.core.widget.addTextChangedListener
10+
import androidx.fragment.app.DialogFragment
11+
import androidx.fragment.app.setFragmentResult
12+
import com.chiller3.bcr.Preferences
13+
import com.chiller3.bcr.R
14+
import com.chiller3.bcr.databinding.DialogTextInputBinding
15+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
16+
17+
class MinDurationDialogFragment : DialogFragment() {
18+
companion object {
19+
val TAG: String = MinDurationDialogFragment::class.java.simpleName
20+
21+
const val RESULT_SUCCESS = "success"
22+
}
23+
24+
private lateinit var prefs: Preferences
25+
private lateinit var binding: DialogTextInputBinding
26+
private var minDuration: Int? = null
27+
private var success: Boolean = false
28+
29+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
30+
val context = requireContext()
31+
prefs = Preferences(context)
32+
minDuration = prefs.minDuration
33+
34+
binding = DialogTextInputBinding.inflate(layoutInflater)
35+
36+
binding.message.setText(R.string.min_duration_dialog_message)
37+
38+
binding.text.inputType = InputType.TYPE_CLASS_NUMBER
39+
binding.text.addTextChangedListener {
40+
minDuration = if (it!!.isEmpty()) {
41+
0
42+
} else {
43+
try {
44+
val seconds = it.toString().toInt()
45+
if (seconds >= 0) {
46+
seconds
47+
} else {
48+
null
49+
}
50+
} catch (e: NumberFormatException) {
51+
null
52+
}
53+
}
54+
55+
refreshHelperText()
56+
refreshOkButtonEnabledState()
57+
}
58+
if (savedInstanceState == null) {
59+
binding.text.setText(minDuration?.toString())
60+
}
61+
62+
refreshHelperText()
63+
64+
return MaterialAlertDialogBuilder(requireContext())
65+
.setTitle(R.string.min_duration_dialog_title)
66+
.setView(binding.root)
67+
.setPositiveButton(android.R.string.ok) { _, _ ->
68+
prefs.minDuration = minDuration!!
69+
success = true
70+
}
71+
.setNegativeButton(android.R.string.cancel, null)
72+
.create()
73+
.apply {
74+
setCanceledOnTouchOutside(false)
75+
}
76+
}
77+
78+
override fun onStart() {
79+
super.onStart()
80+
refreshOkButtonEnabledState()
81+
}
82+
83+
override fun onDismiss(dialog: DialogInterface) {
84+
super.onDismiss(dialog)
85+
86+
setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success))
87+
}
88+
89+
private fun refreshHelperText() {
90+
val context = requireContext()
91+
92+
binding.textLayout.helperText = minDuration?.let {
93+
context.resources.getQuantityString(R.plurals.min_duration_dialog_seconds, it, it)
94+
}
95+
}
96+
97+
private fun refreshOkButtonEnabledState() {
98+
(dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled =
99+
minDuration != null
100+
}
101+
}

app/src/main/java/com/chiller3/bcr/settings/SettingsFragment.kt

+28-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.chiller3.bcr.DirectBootMigrationService
2121
import com.chiller3.bcr.Permissions
2222
import com.chiller3.bcr.Preferences
2323
import com.chiller3.bcr.R
24+
import com.chiller3.bcr.dialog.MinDurationDialogFragment
2425
import com.chiller3.bcr.extension.formattedString
2526
import com.chiller3.bcr.format.Format
2627
import com.chiller3.bcr.format.NoParamInfo
@@ -40,6 +41,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan
4041
private lateinit var prefRecordRules: Preference
4142
private lateinit var prefOutputDir: LongClickablePreference
4243
private lateinit var prefOutputFormat: Preference
44+
private lateinit var prefMinDuration: Preference
4345
private lateinit var prefInhibitBatteryOpt: SwitchPreferenceCompat
4446
private lateinit var prefVersion: LongClickablePreference
4547
private lateinit var prefMigrateDirectBoot: Preference
@@ -120,6 +122,10 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan
120122
prefOutputFormat.onPreferenceClickListener = this
121123
refreshOutputFormat()
122124

125+
prefMinDuration = findPreference(Preferences.PREF_MIN_DURATION)!!
126+
prefMinDuration.onPreferenceClickListener = this
127+
refreshMinDuration()
128+
123129
prefInhibitBatteryOpt = findPreference(Preferences.PREF_INHIBIT_BATT_OPT)!!
124130
prefInhibitBatteryOpt.onPreferenceChangeListener = this
125131

@@ -173,6 +179,16 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan
173179
prefOutputFormat.summary = "${summary}\n\n${format.name} (${prefix}${sampleRateText})"
174180
}
175181

182+
private fun refreshMinDuration() {
183+
val minDuration = prefs.minDuration
184+
185+
prefMinDuration.summary = if (minDuration == 0) {
186+
getString(R.string.pref_min_duration_desc_zero)
187+
} else {
188+
resources.getQuantityString(R.plurals.pref_min_duration_desc, minDuration, minDuration)
189+
}
190+
}
191+
176192
private fun refreshVersion() {
177193
val suffix = if (prefs.isDebugMode) {
178194
"+debugmode"
@@ -232,6 +248,11 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan
232248
)
233249
return true
234250
}
251+
prefMinDuration -> {
252+
MinDurationDialogFragment().show(
253+
parentFragmentManager.beginTransaction(), MinDurationDialogFragment.TAG)
254+
return true
255+
}
235256
prefVersion -> {
236257
val uri = Uri.parse(BuildConfig.PROJECT_URL_AT_COMMIT)
237258
startActivity(Intent(Intent.ACTION_VIEW, uri))
@@ -276,22 +297,26 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan
276297
when {
277298
key == null -> return
278299
// Update the switch state if it was toggled outside of the preference (eg. from the
279-
// quick settings toggle)
300+
// quick settings toggle).
280301
key == prefCallRecording.key -> {
281302
val current = prefCallRecording.isChecked
282303
val expected = sharedPreferences.getBoolean(key, current)
283304
if (current != expected) {
284305
prefCallRecording.isChecked = expected
285306
}
286307
}
287-
// Update the output directory state when it's changed by the bottom sheet
308+
// Update the output directory state when it's changed by the bottom sheet.
288309
key == Preferences.PREF_OUTPUT_DIR || key == Preferences.PREF_OUTPUT_RETENTION -> {
289310
refreshOutputDir()
290311
}
291-
// Update the output format state when it's changed by the bottom sheet
312+
// Update the output format state when it's changed by the bottom sheet.
292313
Preferences.isFormatKey(key) || key == Preferences.PREF_SAMPLE_RATE -> {
293314
refreshOutputFormat()
294315
}
316+
// Update when it's changed by the dialog.
317+
key == Preferences.PREF_MIN_DURATION -> {
318+
refreshMinDuration()
319+
}
295320
}
296321
}
297322
}

0 commit comments

Comments
 (0)