diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9857d5303..e9a74b3ad 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,6 +47,19 @@
+
+
+
+
+
+
()
init {
@@ -15,10 +15,10 @@ class FilenameTemplate private constructor(props: Properties) {
while (true) {
val index = components.size
- val text = props.getProperty("filename.$index.text") ?: break
- val default = props.getProperty("filename.$index.default")
- val prefix = props.getProperty("filename.$index.prefix")
- val suffix = props.getProperty("filename.$index.suffix")
+ val text = props.getProperty("$key.$index.text") ?: break
+ val default = props.getProperty("$key.$index.default")
+ val prefix = props.getProperty("$key.$index.prefix")
+ val suffix = props.getProperty("$key.$index.suffix")
components.add(Component(text, default, prefix, suffix))
}
@@ -101,7 +101,7 @@ class FilenameTemplate private constructor(props: Properties) {
}
}.isNotEmpty()
- fun load(context: Context, allowCustom: Boolean): FilenameTemplate {
+ fun load(context: Context, key: String, allowCustom: Boolean): FilenameTemplate {
val props = Properties()
if (allowCustom) {
@@ -120,7 +120,7 @@ class FilenameTemplate private constructor(props: Properties) {
context.contentResolver.openInputStream(templateFile.uri)?.use {
props.load(it)
- return FilenameTemplate(props)
+ return FilenameTemplate(props, key)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load custom filename template", e)
@@ -134,7 +134,7 @@ class FilenameTemplate private constructor(props: Properties) {
context.resources.openRawResource(R.raw.filename_template).use {
props.load(it)
- return FilenameTemplate(props)
+ return FilenameTemplate(props, key)
}
}
}
diff --git a/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt b/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt
new file mode 100644
index 000000000..773de7526
--- /dev/null
+++ b/app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt
@@ -0,0 +1,240 @@
+package com.chiller3.bcr
+
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+import kotlin.random.Random
+
+class RecorderMicTileService : TileService(), RecorderThread.OnRecordingCompletedListener {
+ companion object {
+ private val TAG = RecorderMicTileService::class.java.simpleName
+
+ private val ACTION_PAUSE = "${RecorderMicTileService::class.java.canonicalName}.pause"
+ private val ACTION_RESUME = "${RecorderMicTileService::class.java.canonicalName}.resume"
+ private const val EXTRA_TOKEN = "token"
+ }
+
+ private lateinit var notifications: Notifications
+ private val handler = Handler(Looper.getMainLooper())
+
+ private var recorder: RecorderThread? = null
+
+ private var tileIsListening = false
+
+ /**
+ * Token value for all intents received by this instance of the service.
+ *
+ * For the pause/resume functionality, we cannot use a bound service because [TileService]
+ * uses its own non-extensible [onBind] implementation. So instead, we rely on [onStartCommand].
+ * However, because this service is required to be exported, the intents could potentially come
+ * from third party apps and we don't want those interfering with the recordings.
+ */
+ private val token = Random.Default.nextBytes(128)
+
+ private fun createBaseIntent(): Intent =
+ Intent(this, RecorderMicTileService::class.java).apply {
+ putExtra(EXTRA_TOKEN, token)
+ }
+
+ private fun createPauseIntent(): Intent =
+ createBaseIntent().apply {
+ action = ACTION_PAUSE
+ }
+
+ private fun createResumeIntent(): Intent =
+ createBaseIntent().apply {
+ action = ACTION_RESUME
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ notifications = Notifications(this)
+ }
+
+ /** Handle intents triggered from notification actions for pausing and resuming. */
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ try {
+ val receivedToken = intent?.getByteArrayExtra(EXTRA_TOKEN)
+ if (intent?.action != null && !receivedToken.contentEquals(token)) {
+ throw IllegalArgumentException("Invalid token")
+ }
+
+ when (val action = intent?.action) {
+ ACTION_PAUSE, ACTION_RESUME -> {
+ recorder!!.isPaused = action == ACTION_PAUSE
+ updateForegroundState()
+ }
+ null -> {
+ // Ignore. Hack to keep service alive longer than the tile lifecycle.
+ }
+ else -> throw IllegalArgumentException("Invalid action: $action")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to handle intent: $intent", e)
+ }
+
+ // Kill service if the only reason it is started is due to the intent
+ if (recorder == null) {
+ stopSelf(startId)
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+
+ tileIsListening = true
+
+ refreshTileState()
+ }
+
+ override fun onStopListening() {
+ super.onStopListening()
+
+ tileIsListening = false
+ }
+
+ override fun onClick() {
+ super.onClick()
+
+ if (!Permissions.haveRequired(this)) {
+ val intent = Intent(this, SettingsActivity::class.java)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ startActivityAndCollapse(intent)
+ } else if (recorder == null) {
+ startRecording()
+ } else {
+ requestStopRecording()
+ }
+
+ refreshTileState()
+ }
+
+ private fun refreshTileState() {
+ val tile = qsTile
+
+ // Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted.
+ // Clicking the tile in that state does not invoke the click handler, so it wouldn't be
+ // possible to launch SettingsActivity to grant the permissions.
+ if (Permissions.haveRequired(this) && recorder != null) {
+ tile.state = Tile.STATE_ACTIVE
+ } else {
+ tile.state = Tile.STATE_INACTIVE
+ }
+
+ tile.updateTile()
+ }
+
+ /**
+ * Start the [RecorderThread].
+ *
+ * If the required permissions aren't granted, then the service will stop.
+ *
+ * This function is idempotent.
+ */
+ private fun startRecording() {
+ if (recorder == null) {
+ recorder = try {
+ RecorderThread(this, this, null)
+ } catch (e: Exception) {
+ notifyFailure(e.message, null)
+ throw e
+ }
+
+ // Ensure the service lives past the tile lifecycle
+ startForegroundService(Intent(this, this::class.java))
+ updateForegroundState()
+ recorder!!.start()
+ }
+ }
+
+ /**
+ * Request the cancellation of the [RecorderThread].
+ *
+ * The foreground notification stays alive until the [RecorderThread] exits and reports its
+ * status. The thread may exit before this function is called if an error occurs during
+ * recording.
+ *
+ * This function is idempotent.
+ */
+ private fun requestStopRecording() {
+ recorder?.cancel()
+ }
+
+ private fun updateForegroundState() {
+ if (recorder == null) {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } else {
+ if (recorder!!.isPaused) {
+ startForeground(1, notifications.createPersistentNotification(
+ R.string.notification_recording_mic_paused,
+ R.drawable.ic_launcher_quick_settings,
+ R.string.notification_action_resume,
+ createResumeIntent(),
+ ))
+ } else {
+ startForeground(1, notifications.createPersistentNotification(
+ R.string.notification_recording_mic_in_progress,
+ R.drawable.ic_launcher_quick_settings,
+ R.string.notification_action_pause,
+ createPauseIntent(),
+ ))
+ }
+ }
+ }
+
+ private fun notifySuccess(file: OutputFile) {
+ notifications.notifySuccess(
+ R.string.notification_recording_mic_succeeded,
+ R.drawable.ic_launcher_quick_settings,
+ file,
+ )
+ }
+
+ private fun notifyFailure(errorMsg: String?, file: OutputFile?) {
+ notifications.notifyFailure(
+ R.string.notification_recording_mic_failed,
+ R.drawable.ic_launcher_quick_settings,
+ errorMsg,
+ file,
+ )
+ }
+
+ private fun onThreadExited() {
+ recorder = null
+
+ if (tileIsListening) {
+ refreshTileState()
+ }
+
+ // The service no longer needs to live past the tile lifecycle
+ updateForegroundState()
+ stopSelf()
+ }
+
+ override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) {
+ Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}")
+ handler.post {
+ onThreadExited()
+
+ // 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)
+ }
+ }
+ }
+
+ override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) {
+ Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}")
+ handler.post {
+ onThreadExited()
+
+ notifyFailure(errorMsg, file)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt
index 1a270446b..98337a6d0 100644
--- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt
+++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt
@@ -36,23 +36,25 @@ 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.
+ * @constructor Create a thread for recording a call or the mic. 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.
+ * @param call Used only for determining the output filename and is not saved. If null, then this
+ * thread records from the mic, not from a call.
*/
class RecorderThread(
private val context: Context,
private val listener: OnRecordingCompletedListener,
- call: Call,
+ call: Call?,
) : Thread(RecorderThread::class.java.simpleName) {
private val tag = "${RecorderThread::class.java.simpleName}/${id}"
private val prefs = Preferences(context)
private val isDebug = prefs.isDebugMode
+ private val isMic = call == null
// Thread state
@Volatile private var isCancelled = false
@@ -76,8 +78,8 @@ class RecorderThread(
// Filename
private val filenameLock = Object()
- private var pendingCallDetails: Call.Details? = null
- private lateinit var lastCallDetails: Call.Details
+ private var pendingCallDetails = call?.details
+ private var lastCallDetails: Call.Details? = null
private lateinit var filenameTemplate: FilenameTemplate
private lateinit var filename: String
private val redactions = HashMap()
@@ -112,19 +114,80 @@ class RecorderThread(
Log.i(tag, "Created thread for call: $call")
Log.i(tag, "Initially paused: $isPaused")
- onCallDetailsChanged(call.details)
-
val savedFormat = Format.fromPreferences(prefs)
format = savedFormat.first
formatParam = savedFormat.second
}
+ /**
+ * Format [callTimestamp] based on the date variable [varName].
+ *
+ * This has a side effect of updating [formatter] with the new custom formatter if one is
+ * specified via [varName].
+ */
+ private fun handleDateFormat(varName: String): String {
+ require(varName == "date" || varName.startsWith("date:")) {
+ "Not a date variable: $varName"
+ }
+
+ val colon = varName.indexOf(":")
+ if (colon >= 0) {
+ val pattern = varName.substring(colon + 1)
+ Log.d(tag, "Using custom datetime pattern: $pattern")
+
+ try {
+ formatter = DateTimeFormatterBuilder()
+ .appendPattern(pattern)
+ .toFormatter()
+ } catch (e: Exception) {
+ Log.w(tag, "Invalid custom datetime pattern: $pattern; using default", e)
+ }
+ }
+
+ return formatter.format(callTimestamp)
+ }
+
+ /**
+ * Update [filename] for mic recording.
+ *
+ * This function holds a lock on [filenameLock] until it returns.
+ */
+ private fun setFilenameForMic() {
+ synchronized(filenameLock) {
+ filename = filenameTemplate.evaluate {
+ when {
+ it == "date" || it.startsWith("date:") -> {
+ if (!this::callTimestamp.isInitialized) {
+ callTimestamp = ZonedDateTime.now()
+ }
+
+ return@evaluate handleDateFormat(it)
+ }
+ else -> {
+ Log.w(tag, "Unknown filename template variable: $it")
+ }
+ }
+
+ null
+ }
+ // AOSP's SAF automatically replaces invalid characters with underscores, but just in
+ // case an OEM fork breaks that, do the replacement ourselves to prevent directory
+ // traversal attacks.
+ .replace('/', '_').trim()
+ }
+ }
+
/**
* Update [filename] with information from [details].
*
* This function holds a lock on [filenameLock] until it returns.
*/
- fun onCallDetailsChanged(details: Call.Details) {
+ fun onCallDetailsChanged(details: Call.Details?) {
+ if (details == null) {
+ setFilenameForMic()
+ return
+ }
+
synchronized(filenameLock) {
if (!this::filenameTemplate.isInitialized) {
// Thread hasn't started yet, so we haven't loaded the filename template
@@ -140,21 +203,7 @@ class RecorderThread(
val instant = Instant.ofEpochMilli(details.creationTimeMillis)
callTimestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault())
- val colon = it.indexOf(":")
- if (colon >= 0) {
- val pattern = it.substring(colon + 1)
- Log.d(tag, "Using custom datetime pattern: $pattern")
-
- try {
- formatter = DateTimeFormatterBuilder()
- .appendPattern(pattern)
- .toFormatter()
- } catch (e: Exception) {
- Log.w(tag, "Invalid custom datetime pattern: $pattern; using default", e)
- }
- }
-
- return@evaluate formatter.format(callTimestamp)
+ return@evaluate handleDateFormat(it)
}
it == "direction" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -229,13 +278,15 @@ class RecorderThread(
var errorMsg: String? = null
var resultUri: Uri? = null
+ val templateKey = if (pendingCallDetails == null) { "filename_mic" } else { "filename" }
+
synchronized(filenameLock) {
// We initially do not allow custom filename templates because SAF is extraordinarily
// slow on some devices. Even with the our custom findFileFast() implementation, simply
// checking for the existence of the template may take >500ms.
- filenameTemplate = FilenameTemplate.load(context, false)
+ filenameTemplate = FilenameTemplate.load(context, templateKey, false)
- onCallDetailsChanged(pendingCallDetails!!)
+ onCallDetailsChanged(pendingCallDetails)
pendingCallDetails = null
}
@@ -258,7 +309,7 @@ class RecorderThread(
}
} finally {
val finalFilename = synchronized(filenameLock) {
- filenameTemplate = FilenameTemplate.load(context, true)
+ filenameTemplate = FilenameTemplate.load(context, templateKey, true)
onCallDetailsChanged(lastCallDetails)
filename
@@ -462,8 +513,7 @@ class RecorderThread(
}
/**
- * Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
- * capture or encoding error occurs.
+ * Record until [cancel] is called or an audio capture or encoding error occurs.
*
* [pfd] does not get closed by this method.
*/
@@ -479,7 +529,11 @@ class RecorderThread(
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")
val audioRecord = AudioRecord(
- MediaRecorder.AudioSource.VOICE_CALL,
+ if (isMic) {
+ MediaRecorder.AudioSource.MIC
+ } else {
+ MediaRecorder.AudioSource.VOICE_CALL
+ },
sampleRate.value.toInt(),
CHANNEL_CONFIG,
ENCODING,
diff --git a/app/src/main/res/raw/filename_template.properties b/app/src/main/res/raw/filename_template.properties
index f360ebc2e..dd4deb3e9 100644
--- a/app/src/main/res/raw/filename_template.properties
+++ b/app/src/main/res/raw/filename_template.properties
@@ -67,6 +67,12 @@ filename.5.prefix = _
################################################################################
+# Starting time of recording.
+filename_mic.0.text = ${date}
+filename_mic.0.suffix = _mic
+
+################################################################################
+
# Example: Use a shorter timestamp that only includes the date and time (up to
# seconds). This will result in a timestamp like 20230101_010203.
#filename.0.text = ${date:yyyyMMdd_HHmmss}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5ef87c421..2e2481543 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -51,10 +51,15 @@
Success alerts
Alerts for successful call recordings
Call recording in progress
+ Mic recording in progress
Call recording paused
+ Mic recording paused
Failed to record call
+ Failed to record mic
Successfully recorded call
+ Successfully recorded mic
The recording failed in an internal Android component (%s). This device or firmware might not support call recording.
+
Open
Share
Delete
@@ -63,4 +68,5 @@
Call recording
+ Mic recording