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.