From 1e4400bb4aa0983bf4b24139d128abf3787718e7 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Sun, 20 Nov 2022 17:44:18 -0500 Subject: [PATCH 1/3] Add quick settings tile for recording from microphone This adds a new quick settings tile that starts recording from the microphone when enabled and stops recording when disabled. It is completely unrelated to the call recording functionality, but does not interfere with it either. Signed-off-by: Andrew Gunnerson --- app/src/main/AndroidManifest.xml | 13 + .../java/com/chiller3/bcr/FilenameTemplate.kt | 16 +- .../chiller3/bcr/RecorderMicTileService.kt | 240 ++++++++++++++++++ .../java/com/chiller3/bcr/RecorderThread.kt | 116 ++++++--- .../main/res/raw/filename_template.properties | 6 + app/src/main/res/values/strings.xml | 6 + 6 files changed, 358 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt 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 2585838d9..15d46c686 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 = BuildConfig.DEBUG || 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 From 4673ede7cb11172ab6325d5c28028af73941363b Mon Sep 17 00:00:00 2001 From: Twoomatch <120144321+Twoomatch@users.noreply.github.com> Date: Fri, 9 Dec 2022 22:39:09 +0100 Subject: [PATCH 2/3] Update Polish translations --- app/src/main/res/values-pl/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 264500b47..474ad4bd2 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -53,9 +53,12 @@ Zakończone nagrywanie Informacje o pomyślnym nagraniu rozmowy Trwa nagrywanie rozmowy + Trwa nagrywanie dźwięku Nagrywanie rozmowy wstrzymane Nie udało się nagrać rozmowy + Nie udało się nagrać dźwięku Pomyślnie nagrano rozmowę + Pomyślnie nagrano dźwięk Podczas nagrywania wystąpił błąd w wewnętrznym komponencie Androida (%s). To urządzenie lub system może nie obsługiwać nagrywania rozmów. Otwórz Udostępnij @@ -65,4 +68,5 @@ Nagrywanie rozmów + Nagrywanie dźwięku From e459c66af1ffa5ce0f8ee0e776f8f2ad10706b43 Mon Sep 17 00:00:00 2001 From: Patryk Mis Date: Sat, 18 Mar 2023 17:35:48 +0100 Subject: [PATCH 3/3] a11y: mark tyle as toggleable --- app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e9a74b3ad..beb9bb9e5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,8 @@ android:label="@string/quick_settings_mic_label" android:foregroundServiceType="microphone" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> +