diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c201133ab..37c4a7088 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -58,6 +58,9 @@
+
+
+
+
!isAccessibilityEnabled
"Statusbar icons" -> !isWriteSecureSettingsEnabled
"Notification lighting" -> !isOverlayPermissionGranted || !isNotificationLightingAccessibilityEnabled || !isNotificationListenerEnabled
- "Button remap" -> !isAccessibilityEnabled
+ "Button remap" -> {
+ val needsRecordAudio = viewModel.volumeUpActionOff.value == "Toggle audio recording" ||
+ viewModel.volumeDownActionOff.value == "Toggle audio recording" ||
+ viewModel.volumeUpActionOn.value == "Toggle audio recording" ||
+ viewModel.volumeDownActionOn.value == "Toggle audio recording"
+ !isAccessibilityEnabled || (needsRecordAudio && !com.sameerasw.essentials.utils.PermissionUtils.hasRecordAudioPermission(context))
+ }
"Dynamic night light" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !isWriteSecureSettingsEnabled
"Snooze system notifications" -> !isNotificationListenerEnabled
"Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value
@@ -499,7 +505,13 @@ class FeatureSettingsActivity : AppCompatActivity() {
"Screen off widget" -> !isAccessibilityEnabled
"Statusbar icons" -> !isWriteSecureSettingsEnabled
"Notification lighting" -> !isOverlayPermissionGranted || !isNotificationLightingAccessibilityEnabled || !isNotificationListenerEnabled
- "Button remap" -> !isAccessibilityEnabled
+ "Button remap" -> {
+ val needsRecordAudio = viewModel.volumeUpActionOff.value == "Toggle audio recording" ||
+ viewModel.volumeDownActionOff.value == "Toggle audio recording" ||
+ viewModel.volumeUpActionOn.value == "Toggle audio recording" ||
+ viewModel.volumeDownActionOn.value == "Toggle audio recording"
+ !isAccessibilityEnabled || (needsRecordAudio && !com.sameerasw.essentials.utils.PermissionUtils.hasRecordAudioPermission(context))
+ }
"Dynamic night light" -> (if (viewModel.isUseUsageAccess.value) !viewModel.isUsageStatsPermissionGranted.value else !isAccessibilityEnabled) || !isWriteSecureSettingsEnabled
"Snooze system notifications" -> !isNotificationListenerEnabled
"Screen locked security" -> !isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
index 1679cdfdc..2b008c3ae 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
@@ -722,10 +722,6 @@ object FeatureRegistry {
category = R.string.cat_interaction,
description = R.string.feat_button_remap_desc,
aboutDescription = R.string.about_desc_button_remap,
- permissionKeys = if (ShellUtils.isRootEnabled(EssentialsApp.context)) listOf(
- "ACCESSIBILITY",
- "ROOT"
- ) else listOf("ACCESSIBILITY", "SHIZUKU"),
showToggle = true,
searchableSettings = listOf(
SearchSetting(
@@ -750,6 +746,20 @@ object FeatureRegistry {
parentFeatureId = "Input",
animationRes = R.raw.button_animation
) {
+ override val permissionKeys: List
+ get() {
+ val baseKeys = if (ShellUtils.isRootEnabled(EssentialsApp.context)) listOf(
+ "ACCESSIBILITY",
+ "ROOT"
+ ) else listOf("ACCESSIBILITY", "SHIZUKU")
+ val repository = com.sameerasw.essentials.data.repository.SettingsRepository(EssentialsApp.context)
+ val needsRecordAudio = repository.getString("button_remap_vol_up_action_off", "None") == "Toggle audio recording" ||
+ repository.getString("button_remap_vol_down_action_off", "None") == "Toggle audio recording" ||
+ repository.getString("button_remap_vol_up_action_on", "None") == "Toggle audio recording" ||
+ repository.getString("button_remap_vol_down_action_on", "None") == "Toggle audio recording"
+ return if (needsRecordAudio) baseKeys + "RECORD_AUDIO" else baseKeys
+ }
+
override fun isEnabled(viewModel: MainViewModel) = viewModel.isButtonRemapEnabled.value
override fun isToggleEnabled(viewModel: MainViewModel, context: Context) =
viewModel.isAccessibilityEnabled.value
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
index 15c9ce40f..1bf2e23ca 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
@@ -97,4 +97,5 @@ fun initPermissionRegistry() {
PermissionRegistry.register("WRITE_SECURE_SETTINGS", R.string.feat_shut_up_title)
PermissionRegistry.register("WRITE_SETTINGS", R.string.feat_shut_up_title)
PermissionRegistry.register("USAGE_STATS", R.string.feat_shut_up_title)
+ PermissionRegistry.register("RECORD_AUDIO", R.string.feat_button_remap_title)
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/AudioRecordService.kt b/app/src/main/java/com/sameerasw/essentials/services/AudioRecordService.kt
new file mode 100644
index 000000000..d9ef771a5
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/AudioRecordService.kt
@@ -0,0 +1,252 @@
+package com.sameerasw.essentials.services
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.media.MediaRecorder
+import android.os.Build
+import android.os.Environment
+import android.os.IBinder
+import android.widget.Toast
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.sameerasw.essentials.R
+import java.io.File
+
+class AudioRecordService : Service() {
+
+ private var mediaRecorder: MediaRecorder? = null
+ private var recordedFile: File? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (isRecording) {
+ stopRecording()
+ } else {
+ startRecording()
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ if (isRecording) {
+ try {
+ mediaRecorder?.apply {
+ stop()
+ release()
+ }
+ val privateFile = recordedFile
+ if (privateFile != null && privateFile.exists()) {
+ val publicUri = saveToPublicMusic(privateFile)
+ if (publicUri != null) {
+ privateFile.delete()
+ showSavedNotification(publicUri)
+ } else {
+ val authority = "${packageName}.fileprovider"
+ val privateUri = androidx.core.content.FileProvider.getUriForFile(this, authority, privateFile)
+ showSavedNotification(privateUri)
+ }
+ }
+ } catch (_: Exception) {}
+ mediaRecorder = null
+ isRecording = false
+ }
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startRecording() {
+ if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO)
+ != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ Toast.makeText(this, "Audio record permission required", Toast.LENGTH_SHORT).show()
+ stopSelf()
+ return
+ }
+
+ val musicDir = getExternalFilesDir(Environment.DIRECTORY_MUSIC)
+ if (musicDir == null) {
+ Toast.makeText(this, "Failed to access storage", Toast.LENGTH_SHORT).show()
+ stopSelf()
+ return
+ }
+
+ val outputFile = File(musicDir, "recording_${System.currentTimeMillis()}.m4a")
+ recordedFile = outputFile
+
+ try {
+ val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(this)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+
+ recorder.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setOutputFile(outputFile.absolutePath)
+ prepare()
+ start()
+ }
+ mediaRecorder = recorder
+ isRecording = true
+
+ startForeground(NOTIF_ID, buildNotification())
+ Toast.makeText(this, "Recording started", Toast.LENGTH_SHORT).show()
+ } catch (e: Exception) {
+ Toast.makeText(this, "Failed to start recording: ${e.message}", Toast.LENGTH_SHORT).show()
+ stopSelf()
+ }
+ }
+
+ private fun stopRecording() {
+ try {
+ mediaRecorder?.apply {
+ stop()
+ release()
+ }
+ val privateFile = recordedFile
+ if (privateFile != null && privateFile.exists()) {
+ val publicUri = saveToPublicMusic(privateFile)
+ if (publicUri != null) {
+ privateFile.delete()
+ showSavedNotification(publicUri)
+ } else {
+ val authority = "${packageName}.fileprovider"
+ val privateUri = androidx.core.content.FileProvider.getUriForFile(this, authority, privateFile)
+ showSavedNotification(privateUri)
+ }
+ }
+ } catch (_: Exception) {
+ } finally {
+ mediaRecorder = null
+ isRecording = false
+ }
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ Toast.makeText(this, "Recording saved", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun saveToPublicMusic(file: File): android.net.Uri? {
+ try {
+ val resolver = contentResolver
+ val values = android.content.ContentValues().apply {
+ put(android.provider.MediaStore.Audio.Media.DISPLAY_NAME, file.name)
+ put(android.provider.MediaStore.Audio.Media.MIME_TYPE, "audio/mp4")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ put(android.provider.MediaStore.Audio.Media.RELATIVE_PATH, Environment.DIRECTORY_MUSIC + "/Essentials")
+ put(android.provider.MediaStore.Audio.Media.IS_PENDING, 1)
+ }
+ }
+
+ val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.provider.MediaStore.Audio.Media.getContentUri(android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY)
+ } else {
+ android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ }
+
+ val itemUri = resolver.insert(collection, values) ?: return null
+
+ resolver.openOutputStream(itemUri)?.use { outStream ->
+ file.inputStream().use { inStream ->
+ inStream.copyTo(outStream)
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ values.clear()
+ values.put(android.provider.MediaStore.Audio.Media.IS_PENDING, 0)
+ resolver.update(itemUri, values, null, null)
+ }
+
+ return itemUri
+ } catch (e: Exception) {
+ e.printStackTrace()
+ return null
+ }
+ }
+
+ private fun showSavedNotification(uri: android.net.Uri) {
+ try {
+ val viewIntent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "audio/*")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ viewIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.rounded_mic_24)
+ .setContentTitle(getString(R.string.audio_record_saved_title))
+ .setContentText(getString(R.string.audio_record_saved_desc))
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .build()
+
+ notificationManager.notify(43, notification)
+ } catch (e: Exception) {
+ Toast.makeText(this, "Failed to create saved notification: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun buildNotification(): Notification {
+ val stopIntent = Intent(this, AudioRecordService::class.java).apply {
+ action = ACTION_TOGGLE_RECORDING
+ }
+ val stopPendingIntent = PendingIntent.getService(
+ this,
+ 0,
+ stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.rounded_mic_24)
+ .setContentTitle(getString(R.string.audio_record_notif_title))
+ .setOngoing(true)
+ .addAction(
+ R.drawable.rounded_mic_24,
+ getString(R.string.audio_record_notif_stop),
+ stopPendingIntent
+ )
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ val name = getString(R.string.audio_record_notif_channel_name)
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ name,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ const val ACTION_TOGGLE_RECORDING = "com.sameerasw.essentials.ACTION_TOGGLE_RECORDING"
+ const val NOTIF_ID = 42
+ const val CHANNEL_ID = "audio_record_channel"
+ var isRecording = false
+ private set
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt
index 384b12731..13408cb22 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt
@@ -13,6 +13,7 @@ import android.os.VibratorManager
import android.util.Log
import android.view.KeyEvent
import com.sameerasw.essentials.domain.HapticFeedbackType
+import com.sameerasw.essentials.services.AudioRecordService
import com.sameerasw.essentials.services.InputEventListenerService
import com.sameerasw.essentials.utils.performHapticFeedback
@@ -210,7 +211,21 @@ class ButtonRemapHandler(
com.sameerasw.essentials.utils.OmniTriggerUtil.trigger(service)
triggerHapticFeedback()
}
+
+ "Toggle audio recording" -> toggleAudioRecording()
+ }
+ }
+
+ private fun toggleAudioRecording() {
+ val intent = Intent(service, AudioRecordService::class.java).apply {
+ action = AudioRecordService.ACTION_TOGGLE_RECORDING
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ service.startForegroundService(intent)
+ } else {
+ service.startService(intent)
+ }
+ triggerHapticFeedback()
}
private fun cycleSoundModes() {
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt
index 83a97c10a..50ba05436 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt
@@ -48,6 +48,9 @@ import com.sameerasw.essentials.ui.components.cards.IconToggleItem
import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
import com.sameerasw.essentials.ui.components.pickers.HapticFeedbackPicker
import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import com.sameerasw.essentials.utils.PermissionUtils
import com.sameerasw.essentials.ui.modifiers.highlight
import com.sameerasw.essentials.utils.HapticUtil
import com.sameerasw.essentials.viewmodels.MainViewModel
@@ -79,6 +82,23 @@ fun ButtonRemapSettingsUI(
var shizukuStatus by remember { mutableStateOf(ShizukuStatus.NOT_RUNNING) }
val shizukuHelper = remember { ShizukuPermissionHelper(context) }
+ val recordAudioPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ when (selectedScreenTab) {
+ 0 -> {
+ if (selectedButtonTab == 0) viewModel.setVolumeUpActionOff("Toggle audio recording", context)
+ else viewModel.setVolumeDownActionOff("Toggle audio recording", context)
+ }
+ 1 -> {
+ if (selectedButtonTab == 0) viewModel.setVolumeUpActionOn("Toggle audio recording", context)
+ else viewModel.setVolumeDownActionOn("Toggle audio recording", context)
+ }
+ }
+ }
+ }
+
// Check Shizuku status on resume
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
@@ -405,6 +425,18 @@ fun ButtonRemapSettingsUI(
onClick = { onActionSelected("Circle to Search") },
iconRes = R.drawable.frame_inspect_24px,
)
+ RemapActionItem(
+ title = stringResource(R.string.action_toggle_audio_recording),
+ isSelected = currentAction == "Toggle audio recording",
+ onClick = {
+ if (PermissionUtils.hasRecordAudioPermission(context)) {
+ onActionSelected("Toggle audio recording")
+ } else {
+ recordAudioPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO)
+ }
+ },
+ iconRes = R.drawable.rounded_mic_24,
+ )
if (selectedScreenTab == 1) {
RemapActionItem(
title = stringResource(R.string.action_take_screenshot),
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt
index abc65df74..f6e772169 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUIHelper.kt
@@ -230,6 +230,24 @@ object PermissionUIHelper {
isGranted = viewModel.isCalendarPermissionGranted.value
)
+ "RECORD_AUDIO" -> PermissionItem(
+ iconRes = R.drawable.rounded_mic_24,
+ title = R.string.permission_record_audio_title,
+ description = R.string.permission_record_audio_desc,
+ dependentFeatures = PermissionRegistry.getFeatures("RECORD_AUDIO"),
+ actionLabel = if (PermissionUtils.hasRecordAudioPermission(context)) R.string.perm_action_granted else R.string.perm_action_grant,
+ action = {
+ if (activity != null) {
+ ActivityCompat.requestPermissions(
+ activity,
+ arrayOf(android.Manifest.permission.RECORD_AUDIO),
+ 106
+ )
+ }
+ },
+ isGranted = PermissionUtils.hasRecordAudioPermission(context)
+ )
+
"USAGE_STATS" -> PermissionItem(
iconRes = R.drawable.rounded_data_usage_24,
title = R.string.perm_usage_stats_title,
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
index 235ed8434..4aac50316 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
@@ -269,4 +269,11 @@ object PermissionUtils {
} catch (e: Exception) {
}
}
+
+ fun hasRecordAudioPermission(context: Context): Boolean {
+ return androidx.core.content.ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.RECORD_AUDIO
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
}
diff --git a/app/src/main/res/drawable/rounded_mic_24.xml b/app/src/main/res/drawable/rounded_mic_24.xml
new file mode 100644
index 000000000..def15aec8
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_mic_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8caf42e3c..93c1c1665 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -65,6 +65,14 @@
Cycle sound modes
Circle to Search
Like current song
+ Toggle audio recording
+ Audio Recording
+ Recording audio…
+ Stop
+ Recording saved
+ Tap to open recording
+ Record Audio
+ Required to record audio when remapped hardware button is pressed.
Like song settings
This feature requires notification access to detect the currently playing media and trigger the like action. Please enable it below.
Show toast message
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
index d4e9e40e8..d0c7bbdba 100644
--- a/app/src/main/res/xml/file_paths.xml
+++ b/app/src/main/res/xml/file_paths.xml
@@ -2,4 +2,5 @@
+