From 14154977a3eb496f4f5f0b153105568ae52e3fd1 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Sat, 6 Jun 2026 13:47:03 +0530 Subject: [PATCH] feat: add audio recording action on button remap --- app/src/main/AndroidManifest.xml | 7 + .../essentials/FeatureSettingsActivity.kt | 16 +- .../domain/registry/FeatureRegistry.kt | 18 +- .../domain/registry/PermissionRegistry.kt | 1 + .../essentials/services/AudioRecordService.kt | 252 ++++++++++++++++++ .../services/handlers/ButtonRemapHandler.kt | 15 ++ .../configs/ButtonRemapSettingsUI.kt | 32 +++ .../essentials/utils/PermissionUIHelper.kt | 18 ++ .../essentials/utils/PermissionUtils.kt | 7 + app/src/main/res/drawable/rounded_mic_24.xml | 10 + app/src/main/res/values/strings.xml | 8 + app/src/main/res/xml/file_paths.xml | 1 + 12 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/AudioRecordService.kt create mode 100644 app/src/main/res/drawable/rounded_mic_24.xml 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 @@ +