-
Notifications
You must be signed in to change notification settings - Fork 96
/
RecorderThread.kt
621 lines (528 loc) · 22.1 KB
/
RecorderThread.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
package com.chiller3.bcr
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.Int64Ref
import android.system.Os
import android.system.OsConstants
import android.telecom.Call
import android.telecom.PhoneAccount
import android.util.Log
import androidx.core.net.toFile
import androidx.documentfile.provider.DocumentFile
import com.chiller3.bcr.format.Encoder
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.format.SampleRate
import java.io.IOException
import java.lang.Process
import java.nio.ByteBuffer
import java.time.Duration
import java.time.Instant
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 android.os.Process as AndroidProcess
/**
* Captures call audio and encodes it into an output file in the user's selected directory or the
* fallback/default directory.
*
* @constructor Create a thread for recording a call. Note that the system only has a single
* [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the recorded
* audio for each call may not be as expected.
* @param context Used for querying shared preferences and accessing files via SAF. A reference is
* kept in the object.
* @param listener Used for sending completion notifications. The listener is called from this
* thread, not the main thread.
* @param call Used only for determining the output filename and is not saved.
*/
class RecorderThread(
private val context: Context,
private val listener: OnRecordingCompletedListener,
call: Call,
) : Thread(RecorderThread::class.java.simpleName) {
private val tag = "${RecorderThread::class.java.simpleName}/${id}"
private val prefs = Preferences(context)
private val isDebug = BuildConfig.DEBUG || prefs.isDebugMode
// Thread state
@Volatile private var isCancelled = false
private var captureFailed = false
// Timestamp
private lateinit var callTimestamp: ZonedDateTime
// Filename
private val filenameLock = Object()
private lateinit var filename: String
private val redactions = HashMap<String, String>()
// Format
private val format: Format
private val formatParam: UInt?
private val sampleRate = SampleRate.fromPreferences(prefs)
// Logging
private lateinit var logcatFile: DocumentFile
private lateinit var logcatProcess: Process
init {
Log.i(tag, "Created thread for call: $call")
onCallDetailsChanged(call.details)
val savedFormat = Format.fromPreferences(prefs)
format = savedFormat.first
formatParam = savedFormat.second
}
private fun redact(msg: String): String {
synchronized(filenameLock) {
var result = msg
for ((source, target) in redactions) {
result = result.replace(source, target)
}
return result
}
}
fun redact(uri: Uri): String = redact(Uri.decode(uri.toString()))
/**
* Update [filename] with information from [details].
*
* This function holds a lock on [filenameLock] until it returns.
*/
fun onCallDetailsChanged(details: Call.Details) {
synchronized(filenameLock) {
redactions.clear()
filename = buildString {
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
append(FORMATTER.format(callTimestamp))
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 -> {}
}
}
if (details.handle?.scheme == PhoneAccount.SCHEME_TEL) {
append('_')
append(details.handle.schemeSpecificPart)
redactions[details.handle.schemeSpecificPart] = "<phone number>"
}
val callerName = details.callerDisplayName?.trim()
if (!callerName.isNullOrBlank()) {
append('_')
append(callerName)
redactions[callerName] = "<caller name>"
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val contactName = details.contactDisplayName?.trim()
if (!contactName.isNullOrBlank()) {
append('_')
append(contactName)
redactions[contactName] = "<contact name>"
}
}
}
// 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)}")
}
}
override fun run() {
var success = false
var errorMsg: String? = null
var resultUri: Uri? = null
startLogcat()
try {
Log.i(tag, "Recording thread started")
if (isCancelled) {
Log.i(tag, "Recording cancelled before it began")
} else {
val initialFilename = synchronized(filenameLock) { filename }
val outputFile = createFileInDefaultDir(initialFilename, format.mimeTypeContainer)
resultUri = outputFile.uri
try {
openFile(outputFile, true).use {
recordUntilCancelled(it)
Os.fsync(it.fileDescriptor)
}
} finally {
val finalFilename = synchronized(filenameLock) { filename }
if (finalFilename != initialFilename) {
Log.i(tag, "Renaming ${redact(initialFilename)} to ${redact(finalFilename)}")
if (outputFile.renameTo(finalFilename)) {
resultUri = outputFile.uri
} else {
Log.w(tag, "Failed to rename to final filename: ${redact(finalFilename)}")
}
}
tryMoveToUserDir(outputFile)?.let {
resultUri = it.uri
}
processRetention()
}
success = !captureFailed
}
} catch (e: Exception) {
Log.e(tag, "Error during recording", e)
errorMsg = e.localizedMessage
} finally {
Log.i(tag, "Recording thread completed")
try {
stopLogcat()
} catch (e: Exception) {
Log.w(tag, "Failed to dump logcat", e)
}
if (success) {
listener.onRecordingCompleted(this, resultUri!!)
} else {
listener.onRecordingFailed(this, errorMsg, resultUri)
}
}
}
/**
* Cancel current recording. This stops capturing audio after processing the next minimum buffer
* size, but the thread does not exit until all data encoded so far has been written to the
* output file.
*
* If called before [start], the thread will not record any audio not create an output file. In
* this scenario, [OnRecordingCompletedListener.onRecordingFailed] will be called with a null
* [Uri].
*/
fun cancel() {
Log.d(tag, "Requested cancellation")
isCancelled = true
}
private fun startLogcat() {
if (!isDebug) {
return
}
Log.d(tag, "Starting log file (${BuildConfig.VERSION_NAME})")
logcatFile = createFileInDefaultDir("${filename}.log", "text/plain")
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
// created in the default output directory.
.redirectOutput(logcatFile.uri.toFile())
.redirectErrorStream(true)
.start()
}
private fun stopLogcat() {
if (!isDebug) {
return
}
try {
try {
Log.d(tag, "Stopping log file")
// Give logcat a bit of time to flush the output. It does not have any special
// handling to flush buffers when interrupted.
sleep(1000)
logcatProcess.destroy()
} finally {
logcatProcess.waitFor()
}
} finally {
tryMoveToUserDir(logcatFile)
}
}
/**
* Delete files older than the specified retention period.
*
* The "current time" is [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.
*/
private fun processRetention() {
val directory = prefs.outputDir?.let {
// Only returns null on API <21
DocumentFile.fromTreeUri(context, it)!!
} ?: DocumentFile.fromFile(prefs.defaultOutputDir)
val retention = when (val r = Retention.fromPreferences(prefs)) {
NoRetention -> {
Log.i(tag, "Keeping all existing files")
return
}
is DaysRetention -> r.toDuration()
}
Log.i(tag, "Retention period is $retention")
for (item in directory.listFiles()) {
val filename = item.name ?: continue
val redacted = redactTruncate(filename)
val timestamp = timestampFromFilename(filename)
if (timestamp == null) {
Log.w(tag, "Ignoring unrecognized filename: $redacted")
continue
}
val diff = Duration.between(timestamp, callTimestamp)
if (diff > retention) {
Log.i(tag, "Deleting $redacted ($timestamp)")
if (!item.delete()) {
Log.w(tag, "Failed to delete: $redacted")
}
}
}
}
/**
* Try to move [sourceFile] to the user output directory.
*
* @return Whether the user output directory is set and the file was successfully moved
*/
private fun tryMoveToUserDir(sourceFile: DocumentFile): DocumentFile? {
val userDir = prefs.outputDir?.let {
// Only returns null on API <21
DocumentFile.fromTreeUri(context, it)!!
} ?: return null
val redactedSource = redact(sourceFile.uri)
return try {
val targetFile = moveFileToDir(sourceFile, userDir)
val redactedTarget = redact(targetFile.uri)
Log.i(tag, "Successfully moved $redactedSource to $redactedTarget")
sourceFile.delete()
targetFile
} catch (e: Exception) {
Log.e(tag, "Failed to move $redactedSource to $userDir", e)
null
}
}
/**
* Move [sourceFile] to [targetDir].
*
* @return The [DocumentFile] for the newly moved file.
*/
private fun moveFileToDir(sourceFile: DocumentFile, targetDir: DocumentFile): DocumentFile {
val targetFile = createFileInDir(targetDir, sourceFile.name!!, sourceFile.type!!)
try {
openFile(sourceFile, false).use { sourcePfd ->
openFile(targetFile, true).use { targetPfd ->
var remain = Os.lseek(sourcePfd.fileDescriptor, 0, OsConstants.SEEK_END)
val offset = Int64Ref(0)
while (remain > 0) {
val ret = Os.sendfile(
targetPfd.fileDescriptor, sourcePfd.fileDescriptor, offset, remain)
if (ret == 0L) {
throw IOException("Unexpected EOF in sendfile()")
}
remain -= ret
}
Os.fsync(targetPfd.fileDescriptor)
}
}
sourceFile.delete()
return targetFile
} catch (e: Exception) {
targetFile.delete()
throw e
}
}
/**
* Create [name] in the default output directory.
*
* @param name Should not contain a file extension
* @param mimeType Determines the file extension
*
* @throws IOException if the file could not be created in the default directory
*/
private fun createFileInDefaultDir(name: String, mimeType: String): DocumentFile {
val defaultDir = DocumentFile.fromFile(prefs.defaultOutputDir)
return createFileInDir(defaultDir, name, mimeType)
}
/**
* Create a new file with name [name] inside [dir].
*
* @param name Should not contain a file extension
* @param mimeType Determines the file extension
*
* @throws IOException if file creation fails
*/
private fun createFileInDir(dir: DocumentFile, name: String, mimeType: String): DocumentFile {
Log.d(tag, "Creating ${redact(name)} with MIME type $mimeType in ${dir.uri}")
return dir.createFile(mimeType, name)
?: throw IOException("Failed to create file in ${dir.uri}")
}
/**
* Open seekable file descriptor to [file].
*
* @throws IOException if [file] cannot be opened
*/
private fun openFile(file: DocumentFile, truncate: Boolean): ParcelFileDescriptor {
val truncParam = if (truncate) { "t" } else { "" }
return context.contentResolver.openFileDescriptor(file.uri, "rw$truncParam")
?: throw IOException("Failed to open file at ${file.uri}")
}
/**
* Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
* capture or encoding error occurs.
*
* [pfd] does not get closed by this method.
*/
@SuppressLint("MissingPermission")
private fun recordUntilCancelled(pfd: ParcelFileDescriptor) {
AndroidProcess.setThreadPriority(AndroidProcess.THREAD_PRIORITY_URGENT_AUDIO)
val minBufSize = AudioRecord.getMinBufferSize(
sampleRate.value.toInt(), CHANNEL_CONFIG, ENCODING)
if (minBufSize < 0) {
throw Exception("Failure when querying minimum buffer size: $minBufSize")
}
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")
val audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_CALL,
sampleRate.value.toInt(),
CHANNEL_CONFIG,
ENCODING,
// On some devices, MediaCodec occasionally has sudden spikes in processing time, so use
// a larger internal buffer to reduce the chance of overrun on the recording side.
minBufSize * 6,
)
val initialBufSize = audioRecord.bufferSizeInFrames *
audioRecord.format.frameSizeInBytesCompat
Log.d(tag, "AudioRecord initial buffer size: $initialBufSize")
Log.d(tag, "AudioRecord format: ${audioRecord.format}")
// Where's my RAII? :(
try {
audioRecord.startRecording()
try {
val container = format.getContainer(pfd.fileDescriptor)
try {
// audioRecord.format has the detected native sample rate
val mediaFormat = format.getMediaFormat(audioRecord.format, formatParam)
val encoder = format.getEncoder(mediaFormat, container)
try {
encoder.start()
try {
encodeLoop(audioRecord, encoder, minBufSize)
} finally {
encoder.stop()
}
} finally {
encoder.release()
}
} finally {
container.release()
}
} finally {
audioRecord.stop()
}
} finally {
audioRecord.release()
}
}
/**
* Main loop for encoding captured raw audio into an output file.
*
* The loop runs forever until [cancel] is called. At that point, no further data will be read
* from [audioRecord] and the remaining output data from [encoder] will be written to the output
* file. If [audioRecord] fails to capture data, the loop will behave as if [cancel] was called
* (ie. abort, but ensuring that the output file is valid).
*
* The approximate amount of time to cancel reading from the audio source is the time it takes
* to process the minimum buffer size. Additionally, additional time is needed to write out the
* remaining encoded data to the output file.
*
* @param audioRecord [AudioRecord.startRecording] must have been called
* @param encoder [Encoder.start] must have been called
* @param bufSize Size of buffer to use for each [AudioRecord.read] operation
*
* @throws Exception if the audio recorder or encoder encounters an error
*/
private fun encodeLoop(audioRecord: AudioRecord, encoder: Encoder, bufSize: Int) {
var numFrames = 0L
val frameSize = audioRecord.format.frameSizeInBytesCompat
// Use a slightly larger buffer to reduce the chance of problems under load
val buffer = ByteBuffer.allocateDirect(bufSize * 2)
val bufferFrames = buffer.capacity().toLong() / frameSize
val bufferNs = bufferFrames * 1_000_000_000L / audioRecord.sampleRate
while (!isCancelled) {
val begin = System.nanoTime()
val n = audioRecord.read(buffer, buffer.remaining())
val recordElapsed = System.nanoTime() - begin
var encodeElapsed = 0L
if (n < 0) {
Log.e(tag, "Error when reading samples from $audioRecord: $n")
isCancelled = true
captureFailed = true
} else if (n == 0) {
Log.e(tag, "Unexpected EOF from AudioRecord")
isCancelled = true
} else {
buffer.limit(n)
val encodeBegin = System.nanoTime()
encoder.encode(buffer, false)
encodeElapsed = System.nanoTime() - encodeBegin
buffer.clear()
numFrames += n / frameSize
}
val totalElapsed = System.nanoTime() - begin
if (encodeElapsed > bufferNs) {
Log.w(tag, "${encoder.javaClass.simpleName} took too long: " +
"timestamp=${numFrames.toDouble() / audioRecord.sampleRate}s, " +
"buffer=${bufferNs / 1_000_000.0}ms, " +
"total=${totalElapsed / 1_000_000.0}ms, " +
"record=${recordElapsed / 1_000_000.0}ms, " +
"encode=${encodeElapsed / 1_000_000.0}ms")
}
}
// Signal EOF with empty buffer
Log.d(tag, "Sending EOF to encoder")
buffer.limit(buffer.position())
encoder.encode(buffer, true)
val durationSecs = numFrames.toDouble() / audioRecord.sampleRate
Log.d(tag, "Input complete after ${"%.1f".format(durationSecs)}s")
}
companion object {
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO
private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
// Eg. 20220429_180249.123-0400
private val FORMATTER = DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
.appendValue(ChronoField.MONTH_OF_YEAR, 2)
.appendValue(ChronoField.DAY_OF_MONTH, 2)
.appendLiteral('_')
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
.appendOffset("+HHMMss", "+0000")
.toFormatter()
private fun timestampFromFilename(name: String): ZonedDateTime? {
try {
// Date is before first separator
val first = name.indexOf('_')
if (first < 0 || first == name.length - 1) {
return null
}
val second = name.indexOf('_', first + 1)
if (second < 0) {
return null
}
return ZonedDateTime.parse(name.substring(0, second), FORMATTER)
} catch (e: DateTimeParseException) {
// Ignore
}
return null
}
private fun redactTruncate(msg: String): String = buildString {
val n = 2
if (msg.length > 2 * n) {
append(msg.substring(0, n))
}
append("<...>")
if (msg.length > 2 * n) {
append(msg.substring(msg.length - n))
}
}
}
interface OnRecordingCompletedListener {
/**
* Called when the recording completes successfully. [uri] is the output file.
*/
fun onRecordingCompleted(thread: RecorderThread, uri: Uri)
/**
* Called when an error occurs during recording. If [uri] is not null, it points to the
* output file containing partially recorded audio. If [uri] is null, then either the output
* file could not be created or the thread was cancelled before it was started.
*/
fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, uri: Uri?)
}
}