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