Skip to content
Draft
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
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<application
android:name=".EssentialsApp"
android:allowBackup="true"
Expand Down Expand Up @@ -512,6 +515,10 @@
<property android:name="android.app.property.FOREGROUND_SERVICE_TYPE_SPECIAL_USE_DESCRIPTION"
android:value="Battery Notification Service" />
</service>
<service
android:name=".services.AudioRecordService"
android:exported="false"
android:foregroundServiceType="microphone" />
<service
android:name=".services.tiles.SoundModeTileService"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -750,6 +746,20 @@ object FeatureRegistry {
parentFeatureId = "Input",
animationRes = R.raw.button_animation
) {
override val permissionKeys: List<String>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down
Loading