From a2da0a2ef0027d4eed2ac9e64f51169d7da68d63 Mon Sep 17 00:00:00 2001 From: Lon Doppler Date: Sat, 7 Mar 2026 12:38:34 -0300 Subject: [PATCH] feat(bluetooth): add Bluetooth recording it also improves Android 12+ compat and reduce warnings on build --- CHANGELOG.md | 5 + app/detekt-baseline.xml | 2 + app/src/main/AndroidManifest.xml | 2 + .../activities/BackgroundRecordActivity.kt | 2 +- .../voicerecorder/activities/MainActivity.kt | 5 + .../adapters/ViewPagerAdapter.kt | 5 + .../fragments/RecorderFragment.kt | 130 +++++++++++++++ .../helpers/BluetoothScoManager.kt | 59 +++++++ .../voicerecorder/helpers/Constants.kt | 3 + .../recorder/MediaRecorderWrapper.kt | 30 +++- .../voicerecorder/recorder/Mp3Recorder.kt | 9 +- .../voicerecorder/recorder/Recorder.kt | 2 + .../voicerecorder/services/RecorderService.kt | 150 +++++++++++++----- .../res/drawable/ic_microphone_vector.xml | 9 ++ .../res/drawable/tab_selector_background.xml | 9 ++ .../res/drawable/tab_selector_selected.xml | 6 + app/src/main/res/layout/fragment_recorder.xml | 40 ++++- app/src/main/res/values/strings.xml | 3 + 18 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt create mode 100644 app/src/main/res/drawable/ic_microphone_vector.xml create mode 100644 app/src/main/res/drawable/tab_selector_background.xml create mode 100644 app/src/main/res/drawable/tab_selector_selected.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 786f9e75..0d900ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added Bluetooth audio recording support + +### Changed +- Improved Android 12+ compatibility ## [1.7.1] - 2026-02-14 ### Changed diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index f4dda697..823cd42a 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -27,6 +27,8 @@ TooManyFunctions:Activity.kt$org.fossify.voicerecorder.extensions.Activity.kt TooManyFunctions:Context.kt$org.fossify.voicerecorder.extensions.Context.kt TooManyFunctions:MainActivity.kt$MainActivity : SimpleActivity + TooManyFunctions:MediaRecorderWrapper.kt$MediaRecorderWrapper : Recorder + TooManyFunctions:Mp3Recorder.kt$Mp3Recorder : Recorder TooManyFunctions:PlayerFragment.kt$PlayerFragment : MyViewPagerFragmentRefreshRecordingsListener TooManyFunctions:RecorderFragment.kt$RecorderFragment : MyViewPagerFragment TooManyFunctions:RecorderService.kt$RecorderService : Service diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdb2017a..2c0243e3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/BackgroundRecordActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/BackgroundRecordActivity.kt index e31e7475..ff4f77cd 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/BackgroundRecordActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/BackgroundRecordActivity.kt @@ -20,7 +20,7 @@ class BackgroundRecordActivity : SimpleActivity() { if (RecorderService.isRunning) { stopService(this) } else { - startService(this) + startForegroundService(this) } } catch (ignored: Exception) { } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt index f790855e..94147b98 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/activities/MainActivity.kt @@ -96,6 +96,11 @@ class MainActivity : SimpleActivity() { getPagerAdapter()?.onResume() } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + getPagerAdapter()?.onPermissionResult(requestCode, grantResults) + } + override fun onPause() { super.onPause() config.lastUsedViewPagerPage = binding.viewPager.currentItem diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt index 28d2bc1b..f9aba73d 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/adapters/ViewPagerAdapter.kt @@ -8,6 +8,7 @@ import org.fossify.voicerecorder.R import org.fossify.voicerecorder.activities.SimpleActivity import org.fossify.voicerecorder.fragments.MyViewPagerFragment import org.fossify.voicerecorder.fragments.PlayerFragment +import org.fossify.voicerecorder.fragments.RecorderFragment import org.fossify.voicerecorder.fragments.TrashFragment class ViewPagerAdapter( @@ -59,6 +60,10 @@ class ViewPagerAdapter( } } + fun onPermissionResult(requestCode: Int, grantResults: IntArray) { + (fragments[0] as? RecorderFragment)?.onPermissionResult(requestCode, grantResults) + } + fun searchTextChanged(text: String) { (fragments[1] as? PlayerFragment)?.onSearchTextChanged(text) if (showRecycleBin) { diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt index 557bfc4d..371549ff 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/fragments/RecorderFragment.kt @@ -1,12 +1,19 @@ package org.fossify.voicerecorder.fragments +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import org.fossify.commons.activities.BaseSimpleActivity import org.fossify.commons.compose.extensions.getActivity import org.fossify.commons.dialogs.ConfirmationDialog @@ -26,7 +33,10 @@ import org.fossify.voicerecorder.databinding.FragmentRecorderBinding import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.ensureStoragePermission import org.fossify.voicerecorder.extensions.setKeepScreenAwake +import org.fossify.voicerecorder.helpers.BluetoothScoManager import org.fossify.voicerecorder.helpers.CANCEL_RECORDING +import org.fossify.voicerecorder.helpers.EXTRA_BT_OUTPUT_DEVICE_ID +import org.fossify.voicerecorder.helpers.EXTRA_PREFERRED_AUDIO_DEVICE_ID import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO import org.fossify.voicerecorder.helpers.RECORDING_PAUSED import org.fossify.voicerecorder.helpers.RECORDING_RUNNING @@ -48,6 +58,7 @@ class RecorderFragment( private var status = RECORDING_STOPPED private var pauseBlinkTimer = Timer() private var bus: EventBus? = null + private var bluetoothSelected = false private lateinit var binding: FragmentRecorderBinding override fun onFinishInflate() { @@ -62,6 +73,18 @@ class RecorderFragment( } refreshView() + refreshBluetoothVisibility() + } + + fun onPermissionResult(requestCode: Int, grantResults: IntArray) { + if (requestCode != BLUETOOTH_PERMISSION_REQUEST_CODE) return + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + && findBluetoothInputDevice() != null + ) { + bluetoothSelected = true + refreshBluetoothVisibility() + refreshDeviceSelectorStatus() + } } override fun onDestroy() { @@ -76,6 +99,7 @@ class RecorderFragment( bus = EventBus.getDefault() bus!!.register(this) + setupTabSelector() updateRecordingDuration(0) binding.toggleRecordingButton.setDebouncedClickListener { val activity = context as? BaseSimpleActivity @@ -123,6 +147,27 @@ class RecorderFragment( binding.saveRecordingButton.applyColorFilter(properTextColor) binding.recorderVisualizer.chunkColor = properPrimaryColor binding.recordingDuration.setTextColor(properTextColor) + refreshDeviceSelectorStatus() + } + + private fun refreshDeviceSelectorStatus() { + val properTextColor = context.getProperTextColor() + val properPrimaryColor = context.getProperPrimaryColor() + val contrastColor = properPrimaryColor.getContrastColor() + + if (bluetoothSelected) { + binding.tabDefault.setBackgroundResource(android.R.color.transparent) + binding.tabDefault.setTextColor(properTextColor) + binding.tabBluetooth.setBackgroundResource(R.drawable.tab_selector_selected) + binding.tabBluetooth.background.applyColorFilter(properPrimaryColor) + binding.tabBluetooth.setTextColor(contrastColor) + } else { + binding.tabDefault.setBackgroundResource(R.drawable.tab_selector_selected) + binding.tabDefault.background.applyColorFilter(properPrimaryColor) + binding.tabDefault.setTextColor(contrastColor) + binding.tabBluetooth.setBackgroundResource(android.R.color.transparent) + binding.tabBluetooth.setTextColor(properTextColor) + } } private fun updateRecordingDuration(duration: Int) { @@ -163,10 +208,85 @@ class RecorderFragment( private fun startRecording() { Intent(context, RecorderService::class.java).apply { + if (bluetoothSelected) { + val inputDevice = findBluetoothInputDevice() + val outputDevice = findBluetoothOutputDevice() + if (inputDevice != null) { + putExtra(EXTRA_PREFERRED_AUDIO_DEVICE_ID, inputDevice.id) + } + if (outputDevice != null) { + putExtra(EXTRA_BT_OUTPUT_DEVICE_ID, outputDevice.id) + } + } context.startService(this) } } + private fun setupTabSelector() { + refreshBluetoothVisibility() + + binding.tabDefault.setDebouncedClickListener { + if (bluetoothSelected) { + bluetoothSelected = false + refreshDeviceSelectorStatus() + } + } + + binding.tabBluetooth.setDebouncedClickListener { + if (!bluetoothSelected) { + ensureBluetoothPermission { + bluetoothSelected = true + refreshDeviceSelectorStatus() + } + } + } + } + + private fun refreshBluetoothVisibility() { + val hasBtDevice = findBluetoothInputDevice() != null + binding.microphoneSelectorHolder.beVisibleIf(hasBtDevice) + if (!hasBtDevice && bluetoothSelected) { + bluetoothSelected = false + refreshDeviceSelectorStatus() + } + } + + private fun hasBluetoothPermission(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + } + + private fun findBluetoothInputDevice(): AudioDeviceInfo? { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + return audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + .firstOrNull { BluetoothScoManager.isBluetoothDevice(it) } + } + + private fun findBluetoothOutputDevice(): AudioDeviceInfo? { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } + ?: audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { BluetoothScoManager.isBluetoothDevice(it) } + } + + @SuppressLint("InlinedApi") + private fun ensureBluetoothPermission(callback: () -> Unit) { + if (hasBluetoothPermission()) { + callback() + return + } + + val activity = context as? BaseSimpleActivity ?: return + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.BLUETOOTH_CONNECT), + BLUETOOTH_PERMISSION_REQUEST_CODE + ) + } + private fun showCancelRecordingDialog() { val activity = context as? BaseSimpleActivity ?: return ConfirmationDialog( @@ -212,6 +332,11 @@ class RecorderFragment( binding.toggleRecordingButton.setImageDrawable(getToggleButtonIcon()) binding.saveRecordingButton.beVisibleIf(status != RECORDING_STOPPED) binding.cancelRecordingButton.beVisibleIf(status != RECORDING_STOPPED) + if (status == RECORDING_STOPPED) { + refreshBluetoothVisibility() + } else { + binding.microphoneSelectorHolder.beVisibleIf(false) + } pauseBlinkTimer.cancel() when (status) { @@ -231,6 +356,7 @@ class RecorderFragment( binding.toggleRecordingButton.alpha = 1f binding.recorderVisualizer.recreate() binding.recordingDuration.text = null + context.getActivity().setKeepScreenAwake(false) } } } @@ -256,4 +382,8 @@ class RecorderFragment( binding.recorderVisualizer.update(amplitude) } } + + companion object { + private const val BLUETOOTH_PERMISSION_REQUEST_CODE = 100 + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt new file mode 100644 index 00000000..c7e99770 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/BluetoothScoManager.kt @@ -0,0 +1,59 @@ +package org.fossify.voicerecorder.helpers + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build + +class BluetoothScoManager(private val audioManager: AudioManager) { + + companion object { + fun isBluetoothDevice(device: AudioDeviceInfo): Boolean { + return device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET) + } + } + + var isActive: Boolean = false + private set + + private var previousAudioMode: Int = AudioManager.MODE_NORMAL + + fun start(device: AudioDeviceInfo? = null, onReady: (() -> Unit)? = null) { + if (isActive) { + onReady?.invoke() + return + } + + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (device != null) { + audioManager.setCommunicationDevice(device) + } + } else { + @Suppress("DEPRECATION") + audioManager.startBluetoothSco() + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = true + } + isActive = true + onReady?.invoke() + } + + fun stop() { + if (!isActive) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + } else { + @Suppress("DEPRECATION") + audioManager.isBluetoothScoOn = false + @Suppress("DEPRECATION") + audioManager.stopBluetoothSco() + } + audioManager.mode = previousAudioMode + isActive = false + } +} diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt index 847b696b..bec8f5a8 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/helpers/Constants.kt @@ -12,6 +12,9 @@ const val STOP_AMPLITUDE_UPDATE = PATH + "STOP_AMPLITUDE_UPDATE" const val TOGGLE_PAUSE = PATH + "TOGGLE_PAUSE" const val CANCEL_RECORDING = PATH + "CANCEL_RECORDING" +const val EXTRA_PREFERRED_AUDIO_DEVICE_ID = "preferred_audio_device_id" +const val EXTRA_BT_OUTPUT_DEVICE_ID = "bt_output_device_id" + const val EXTENSION_M4A = 0 const val EXTENSION_MP3 = 1 const val EXTENSION_OGG = 2 diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt index 6af3e964..4e2add9b 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/MediaRecorderWrapper.kt @@ -2,15 +2,18 @@ package org.fossify.voicerecorder.recorder import android.annotation.SuppressLint import android.content.Context +import android.media.AudioDeviceInfo import android.media.MediaRecorder +import android.os.Build import android.os.ParcelFileDescriptor import org.fossify.voicerecorder.extensions.config -class MediaRecorderWrapper(val context: Context) : Recorder { +class MediaRecorderWrapper(val context: Context, audioSourceOverride: Int? = null) : Recorder { - @Suppress("DEPRECATION") - private var recorder = MediaRecorder().apply { - setAudioSource(context.config.microphoneMode) + private var outputParcelFileDescriptor: ParcelFileDescriptor? = null + + private var recorder = createMediaRecorder().apply { + setAudioSource(audioSourceOverride ?: context.config.microphoneMode) setOutputFormat(context.config.getOutputFormat()) setAudioEncoder(context.config.getAudioEncoder()) setAudioEncodingBitRate(context.config.bitrate) @@ -22,10 +25,18 @@ class MediaRecorderWrapper(val context: Context) : Recorder { } override fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) { + outputParcelFileDescriptor?.close() val pFD = ParcelFileDescriptor.dup(parcelFileDescriptor.fileDescriptor) + outputParcelFileDescriptor = pFD recorder.setOutputFile(pFD.fileDescriptor) } + override fun setPreferredDevice(device: AudioDeviceInfo?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + recorder.setPreferredDevice(device) + } + } + override fun prepare() { recorder.prepare() } @@ -50,9 +61,20 @@ class MediaRecorderWrapper(val context: Context) : Recorder { override fun release() { recorder.release() + outputParcelFileDescriptor?.close() + outputParcelFileDescriptor = null } override fun getMaxAmplitude(): Int { return recorder.maxAmplitude } + + @Suppress("DEPRECATION") + private fun createMediaRecorder(): MediaRecorder { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + } } diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt index 68985145..d46e0dc5 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Mp3Recorder.kt @@ -2,6 +2,7 @@ package org.fossify.voicerecorder.recorder import android.annotation.SuppressLint import android.content.Context +import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioRecord import android.os.ParcelFileDescriptor @@ -18,7 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlin.math.abs -class Mp3Recorder(val context: Context) : Recorder { +class Mp3Recorder(val context: Context, audioSourceOverride: Int? = null) : Recorder { private var mp3buffer: ByteArray = ByteArray(0) private var isPaused = AtomicBoolean(false) private var isStopped = AtomicBoolean(false) @@ -35,7 +36,7 @@ class Mp3Recorder(val context: Context) : Recorder { @SuppressLint("MissingPermission") private val audioRecord = AudioRecord( - context.config.microphoneMode, + audioSourceOverride ?: context.config.microphoneMode, context.config.samplingRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, @@ -46,6 +47,10 @@ class Mp3Recorder(val context: Context) : Recorder { outputPath = path } + override fun setPreferredDevice(device: AudioDeviceInfo?) { + audioRecord.setPreferredDevice(device) + } + override fun prepare() {} override fun start() { diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt index 06a938c0..450c5525 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/recorder/Recorder.kt @@ -1,10 +1,12 @@ package org.fossify.voicerecorder.recorder +import android.media.AudioDeviceInfo import android.os.ParcelFileDescriptor interface Recorder { fun setOutputFile(path: String) fun setOutputFile(parcelFileDescriptor: ParcelFileDescriptor) + fun setPreferredDevice(device: AudioDeviceInfo?) fun prepare() fun start() fun stop() diff --git a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt index 755c6efd..7780c7b4 100644 --- a/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt +++ b/app/src/main/kotlin/org/fossify/voicerecorder/services/RecorderService.kt @@ -6,7 +6,12 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.MediaRecorder import android.media.MediaScannerConnection import android.net.Uri import android.os.IBinder @@ -24,6 +29,7 @@ import org.fossify.commons.extensions.isPathOnSD import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toast import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.commons.helpers.isQPlus import org.fossify.commons.helpers.isRPlus import org.fossify.voicerecorder.BuildConfig import org.fossify.voicerecorder.R @@ -31,8 +37,11 @@ import org.fossify.voicerecorder.activities.SplashActivity import org.fossify.voicerecorder.extensions.config import org.fossify.voicerecorder.extensions.getFormattedFilename import org.fossify.voicerecorder.extensions.updateWidgets +import org.fossify.voicerecorder.helpers.BluetoothScoManager import org.fossify.voicerecorder.helpers.CANCEL_RECORDING import org.fossify.voicerecorder.helpers.EXTENSION_MP3 +import org.fossify.voicerecorder.helpers.EXTRA_BT_OUTPUT_DEVICE_ID +import org.fossify.voicerecorder.helpers.EXTRA_PREFERRED_AUDIO_DEVICE_ID import org.fossify.voicerecorder.helpers.GET_RECORDER_INFO import org.fossify.voicerecorder.helpers.RECORDER_RUNNING_NOTIF_ID import org.fossify.voicerecorder.helpers.RECORDING_PAUSED @@ -65,6 +74,7 @@ class RecorderService : Service() { private var durationTimer = Timer() private var amplitudeTimer = Timer() private var recorder: Recorder? = null + private var bluetoothScoManager: BluetoothScoManager? = null override fun onBind(intent: Intent?): IBinder? = null @@ -76,7 +86,7 @@ class RecorderService : Service() { STOP_AMPLITUDE_UPDATE -> amplitudeTimer.cancel() TOGGLE_PAUSE -> togglePause() CANCEL_RECORDING -> cancelRecording() - else -> startRecording() + else -> startRecording(intent) } return START_NOT_STICKY @@ -85,16 +95,15 @@ class RecorderService : Service() { override fun onDestroy() { super.onDestroy() stopRecording() - isRunning = false updateWidgets(false) } // mp4 output format with aac encoding should produce good enough m4a files according to https://stackoverflow.com/a/33054794/1967672 @SuppressLint("DiscouragedApi") - private fun startRecording() { + private fun startRecording(intent: Intent) { isRunning = true updateWidgets(true) - if (status == RECORDING_RUNNING) { + if (status == RECORDING_RUNNING || status == RECORDING_PAUSED) { return } @@ -108,53 +117,107 @@ class RecorderService : Service() { resultUri = null try { - recorder = if (recordMp3()) { - Mp3Recorder(this) - } else { - MediaRecorderWrapper(this) - } + val preferredDeviceId = intent.getIntExtra(EXTRA_PREFERRED_AUDIO_DEVICE_ID, -1) + val btOutputDeviceId = intent.getIntExtra(EXTRA_BT_OUTPUT_DEVICE_ID, -1) + + if (preferredDeviceId != -1) { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val scoManager = BluetoothScoManager(audioManager) + bluetoothScoManager = scoManager + + val inputDevice = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) + .firstOrNull { it.id == preferredDeviceId } + + // Not setting the output device doesn't seem to enable the microphone. + // So, we set both an OUTPUT device and an INPUT device + val outputDevice = if (btOutputDeviceId != -1) { + audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .firstOrNull { it.id == btOutputDeviceId } + } else { + null + } - if (isRPlus()) { - val fileUri = createDocumentUriUsingFirstParentTreeUri(recordingPath) - createSAFFileSdk30(recordingPath) - resultUri = fileUri - contentResolver.openFileDescriptor(fileUri, "w")!! - .use { recorder?.setOutputFile(it) } - } else if (isPathOnSD(recordingPath)) { - var document = getDocumentFile(recordingPath.getParentPath()) - document = document?.createFile("", recordingPath.getFilenameFromPath()) - check(document != null) { "Failed to create document on SD Card" } - resultUri = document.uri - contentResolver.openFileDescriptor(document.uri, "w")!! - .use { recorder?.setOutputFile(it) } - } else { - recorder?.setOutputFile(recordingPath) - resultUri = FileProvider.getUriForFile( - this, "${BuildConfig.APPLICATION_ID}.provider", File(recordingPath) - ) + if (inputDevice != null && BluetoothScoManager.isBluetoothDevice(inputDevice)) { + scoManager.start(outputDevice ?: inputDevice) { + try { + createAndStartRecorder( + audioSourceOverride = MediaRecorder.AudioSource.VOICE_COMMUNICATION, + preferredDevice = inputDevice + ) + } catch (e: Exception) { + showErrorToast(e) + stopRecording() + } + } + return + } } - recorder?.prepare() - recorder?.start() - duration = 0 - status = RECORDING_RUNNING - broadcastRecorderInfo() - startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) - - durationTimer = Timer() - durationTimer.scheduleAtFixedRate(getDurationUpdateTask(), 1000, 1000) - - startAmplitudeUpdates() + createAndStartRecorder(audioSourceOverride = null, preferredDevice = null) } catch (e: Exception) { showErrorToast(e) stopRecording() } } + private fun createAndStartRecorder(audioSourceOverride: Int?, preferredDevice: AudioDeviceInfo?) { + recorder = if (recordMp3()) { + Mp3Recorder(this, audioSourceOverride) + } else { + MediaRecorderWrapper(this, audioSourceOverride) + } + recorder?.setPreferredDevice(preferredDevice) + + if (isRPlus()) { + val fileUri = createDocumentUriUsingFirstParentTreeUri(recordingPath) + createSAFFileSdk30(recordingPath) + resultUri = fileUri + // For the bluetooth path, we need to set "r" too + contentResolver.openFileDescriptor(fileUri, "rw")!! + .use { recorder?.setOutputFile(it) } + } else if (isPathOnSD(recordingPath)) { + var document = getDocumentFile(recordingPath.getParentPath()) + document = document?.createFile("", recordingPath.getFilenameFromPath()) + check(document != null) { "Failed to create document on SD Card" } + resultUri = document.uri + contentResolver.openFileDescriptor(document.uri, "rw")!! + .use { recorder?.setOutputFile(it) } + } else { + recorder?.setOutputFile(recordingPath) + resultUri = FileProvider.getUriForFile( + this, "${BuildConfig.APPLICATION_ID}.provider", File(recordingPath) + ) + } + + if (isQPlus()) { + startForeground( + RECORDER_RUNNING_NOTIF_ID, + showNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + } else { + startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + } + + recorder?.prepare() + recorder?.start() + duration = 0 + status = RECORDING_RUNNING + broadcastRecorderInfo() + + durationTimer = Timer() + durationTimer.scheduleAtFixedRate(getDurationUpdateTask(), 1000, 1000) + + startAmplitudeUpdates() + } + private fun stopRecording() { durationTimer.cancel() amplitudeTimer.cancel() status = RECORDING_STOPPED + isRunning = false + broadcastStatus() + bluetoothScoManager?.stop() recorder?.apply { try { @@ -184,6 +247,7 @@ class RecorderService : Service() { durationTimer.cancel() amplitudeTimer.cancel() status = RECORDING_STOPPED + bluetoothScoManager?.stop() recorder?.apply { try { @@ -229,7 +293,15 @@ class RecorderService : Service() { status = RECORDING_RUNNING } broadcastStatus() - startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + if (isQPlus()) { + startForeground( + RECORDER_RUNNING_NOTIF_ID, + showNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ) + } else { + startForeground(RECORDER_RUNNING_NOTIF_ID, showNotification()) + } } catch (e: Exception) { showErrorToast(e) } diff --git a/app/src/main/res/drawable/ic_microphone_vector.xml b/app/src/main/res/drawable/ic_microphone_vector.xml new file mode 100644 index 00000000..addea13b --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone_vector.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tab_selector_background.xml b/app/src/main/res/drawable/tab_selector_background.xml new file mode 100644 index 00000000..1f31722f --- /dev/null +++ b/app/src/main/res/drawable/tab_selector_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/tab_selector_selected.xml b/app/src/main/res/drawable/tab_selector_selected.xml new file mode 100644 index 00000000..f6da8092 --- /dev/null +++ b/app/src/main/res/drawable/tab_selector_selected.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_recorder.xml b/app/src/main/res/layout/fragment_recorder.xml index f77a5de4..8089f6a6 100644 --- a/app/src/main/res/layout/fragment_recorder.xml +++ b/app/src/main/res/layout/fragment_recorder.xml @@ -6,6 +6,44 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/microphone_selector_holder" /> Voice performance (low-latency) Voice recognition Unprocessed (raw) + + Device Mic + Bluetooth Can I hide the notification icon during recording? Well, it depends. While you use your device it is no longer possible to fully hide the notifications of apps like this.