Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<ID>TooManyFunctions:Activity.kt$org.fossify.voicerecorder.extensions.Activity.kt</ID>
<ID>TooManyFunctions:Context.kt$org.fossify.voicerecorder.extensions.Context.kt</ID>
<ID>TooManyFunctions:MainActivity.kt$MainActivity : SimpleActivity</ID>
<ID>TooManyFunctions:MediaRecorderWrapper.kt$MediaRecorderWrapper : Recorder</ID>
<ID>TooManyFunctions:Mp3Recorder.kt$Mp3Recorder : Recorder</ID>
<ID>TooManyFunctions:PlayerFragment.kt$PlayerFragment : MyViewPagerFragmentRefreshRecordingsListener</ID>
<ID>TooManyFunctions:RecorderFragment.kt$RecorderFragment : MyViewPagerFragment</ID>
<ID>TooManyFunctions:RecorderService.kt$RecorderService : Service</ID>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class BackgroundRecordActivity : SimpleActivity() {
if (RecorderService.isRunning) {
stopService(this)
} else {
startService(this)
startForegroundService(this)
}
} catch (ignored: Exception) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ class MainActivity : SimpleActivity() {
getPagerAdapter()?.onResume()
}

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
getPagerAdapter()?.onPermissionResult(requestCode, grantResults)
}

override fun onPause() {
super.onPause()
config.lastUsedViewPagerPage = binding.viewPager.currentItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -76,6 +99,7 @@ class RecorderFragment(
bus = EventBus.getDefault()
bus!!.register(this)

setupTabSelector()
updateRecordingDuration(0)
binding.toggleRecordingButton.setDebouncedClickListener {
val activity = context as? BaseSimpleActivity
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -231,6 +356,7 @@ class RecorderFragment(
binding.toggleRecordingButton.alpha = 1f
binding.recorderVisualizer.recreate()
binding.recordingDuration.text = null
context.getActivity().setKeepScreenAwake(false)
}
}
}
Expand All @@ -256,4 +382,8 @@ class RecorderFragment(
binding.recorderVisualizer.update(amplitude)
}
}

companion object {
private const val BLUETOOTH_PERMISSION_REQUEST_CODE = 100
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading