Skip to content

Commit fc1af41

Browse files
committed
Add quick settings tile for recording from microphone
This adds a new quick settings tile that starts recording from the microphone when enabled and stops recording when disabled. It is completely unrelated to the call recording functionality, but does not interfere with it either. Signed-off-by: Andrew Gunnerson <[email protected]>
1 parent 69c9a05 commit fc1af41

File tree

6 files changed

+244
-23
lines changed

6 files changed

+244
-23
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,19 @@
4747
</intent-filter>
4848
</service>
4949

50+
<service
51+
android:name=".RecorderMicTileService"
52+
android:enabled="true"
53+
android:exported="true"
54+
android:icon="@drawable/ic_launcher_quick_settings"
55+
android:label="@string/quick_settings_mic_label"
56+
android:foregroundServiceType="microphone"
57+
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
58+
<intent-filter>
59+
<action android:name="android.service.quicksettings.action.QS_TILE" />
60+
</intent-filter>
61+
</service>
62+
5063
<service
5164
android:name=".RecorderTileService"
5265
android:enabled="true"

app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ import androidx.documentfile.provider.DocumentFile
66
import java.util.*
77
import java.util.regex.Pattern
88

9-
class FilenameTemplate private constructor(props: Properties) {
9+
class FilenameTemplate private constructor(props: Properties, key: String) {
1010
private val components = arrayListOf<Component>()
1111

1212
init {
1313
Log.d(TAG, "Filename template: $props")
1414

1515
while (true) {
1616
val index = components.size
17-
val text = props.getProperty("filename.$index.text") ?: break
18-
val default = props.getProperty("filename.$index.default")
19-
val prefix = props.getProperty("filename.$index.prefix")
20-
val suffix = props.getProperty("filename.$index.suffix")
17+
val text = props.getProperty("$key.$index.text") ?: break
18+
val default = props.getProperty("$key.$index.default")
19+
val prefix = props.getProperty("$key.$index.prefix")
20+
val suffix = props.getProperty("$key.$index.suffix")
2121

2222
components.add(Component(text, default, prefix, suffix))
2323
}
@@ -67,7 +67,7 @@ class FilenameTemplate private constructor(props: Properties) {
6767
private val TAG = FilenameTemplate::class.java.simpleName
6868

6969
private val VAR_PATTERN = Pattern.compile("""\${'$'}\{(\w+)\}""")
70-
private val VAR_DATE = "${'$'}{date}"
70+
private const val VAR_DATE = "${'$'}{date}"
7171

7272
private fun evalVars(input: String, getVar: (String) -> String?): String =
7373
StringBuffer().run {
@@ -85,7 +85,7 @@ class FilenameTemplate private constructor(props: Properties) {
8585
toString()
8686
}
8787

88-
fun load(context: Context): FilenameTemplate {
88+
fun load(context: Context, key: String): FilenameTemplate {
8989
val props = Properties()
9090

9191
val prefs = Preferences(context)
@@ -101,7 +101,7 @@ class FilenameTemplate private constructor(props: Properties) {
101101

102102
context.contentResolver.openInputStream(templateFile.uri)?.use {
103103
props.load(it)
104-
return FilenameTemplate(props)
104+
return FilenameTemplate(props, key)
105105
}
106106
} catch (e: Exception) {
107107
Log.w(TAG, "Failed to load custom filename template", e)
@@ -112,7 +112,7 @@ class FilenameTemplate private constructor(props: Properties) {
112112

113113
context.resources.openRawResource(R.raw.filename_template).use {
114114
props.load(it)
115-
return FilenameTemplate(props)
115+
return FilenameTemplate(props, key)
116116
}
117117
}
118118
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.chiller3.bcr
2+
3+
import android.content.Intent
4+
import android.os.Handler
5+
import android.os.Looper
6+
import android.service.quicksettings.Tile
7+
import android.service.quicksettings.TileService
8+
import android.util.Log
9+
10+
class RecorderMicTileService : TileService(), RecorderThread.OnRecordingCompletedListener {
11+
companion object {
12+
private val TAG = RecorderMicTileService::class.java.simpleName
13+
}
14+
15+
private lateinit var notifications: Notifications
16+
private val handler = Handler(Looper.getMainLooper())
17+
18+
private var recorder: RecorderThread? = null
19+
20+
private var tileIsListening = false
21+
22+
override fun onCreate() {
23+
super.onCreate()
24+
25+
notifications = Notifications(this)
26+
}
27+
28+
override fun onStartListening() {
29+
super.onStartListening()
30+
31+
tileIsListening = true
32+
33+
refreshTileState()
34+
}
35+
36+
override fun onStopListening() {
37+
super.onStopListening()
38+
39+
tileIsListening = false
40+
}
41+
42+
override fun onClick() {
43+
super.onClick()
44+
45+
if (!Permissions.haveRequired(this)) {
46+
val intent = Intent(this, SettingsActivity::class.java)
47+
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
48+
startActivityAndCollapse(intent)
49+
} else if (recorder == null) {
50+
startRecording()
51+
} else {
52+
requestStopRecording()
53+
}
54+
55+
refreshTileState()
56+
}
57+
58+
private fun refreshTileState() {
59+
val tile = qsTile
60+
61+
// Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted.
62+
// Clicking the tile in that state does not invoke the click handler, so it wouldn't be
63+
// possible to launch SettingsActivity to grant the permissions.
64+
if (Permissions.haveRequired(this) && recorder != null) {
65+
tile.state = Tile.STATE_ACTIVE
66+
} else {
67+
tile.state = Tile.STATE_INACTIVE
68+
}
69+
70+
tile.updateTile()
71+
}
72+
73+
/**
74+
* Start the [RecorderThread].
75+
*
76+
* If the required permissions aren't granted, then the service will stop.
77+
*
78+
* This function is idempotent.
79+
*/
80+
private fun startRecording() {
81+
if (recorder == null) {
82+
recorder = try {
83+
RecorderThread(this, this, null)
84+
} catch (e: Exception) {
85+
notifyFailure(e.message, null)
86+
throw e
87+
}
88+
89+
// Ensure the service lives past the tile lifecycle
90+
startForegroundService(Intent(this, this::class.java))
91+
startForeground(1, notifications.createPersistentNotification(
92+
R.string.notification_recording_mic_in_progress,
93+
R.drawable.ic_launcher_quick_settings,
94+
))
95+
recorder!!.start()
96+
}
97+
}
98+
99+
/**
100+
* Request the cancellation of the [RecorderThread].
101+
*
102+
* The foreground notification stays alive until the [RecorderThread] exits and reports its
103+
* status. The thread may exit before this function is called if an error occurs during
104+
* recording.
105+
*
106+
* This function is idempotent.
107+
*/
108+
private fun requestStopRecording() {
109+
recorder?.cancel()
110+
}
111+
112+
private fun notifySuccess(file: OutputFile) {
113+
notifications.notifySuccess(
114+
R.string.notification_recording_mic_succeeded,
115+
R.drawable.ic_launcher_quick_settings,
116+
file,
117+
)
118+
}
119+
120+
private fun notifyFailure(errorMsg: String?, file: OutputFile?) {
121+
notifications.notifyFailure(
122+
R.string.notification_recording_mic_failed,
123+
R.drawable.ic_launcher_quick_settings,
124+
errorMsg,
125+
file,
126+
)
127+
}
128+
129+
private fun onThreadExited() {
130+
recorder = null
131+
132+
if (tileIsListening) {
133+
refreshTileState()
134+
}
135+
136+
// The service no longer needs to live past the tile lifecycle
137+
stopForeground(STOP_FOREGROUND_REMOVE)
138+
stopSelf()
139+
}
140+
141+
override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile) {
142+
Log.i(TAG, "Recording completed: ${thread.id}: ${file.redacted}")
143+
handler.post {
144+
onThreadExited()
145+
146+
notifySuccess(file)
147+
}
148+
}
149+
150+
override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) {
151+
Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}")
152+
handler.post {
153+
onThreadExited()
154+
155+
notifyFailure(errorMsg, file)
156+
}
157+
}
158+
}

app/src/main/java/com/chiller3/bcr/RecorderThread.kt

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,25 @@ import android.os.Process as AndroidProcess
4040
* Captures call audio and encodes it into an output file in the user's selected directory or the
4141
* fallback/default directory.
4242
*
43-
* @constructor Create a thread for recording a call. Note that the system only has a single
44-
* [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the recorded
45-
* audio for each call may not be as expected.
43+
* @constructor Create a thread for recording a call or the mic. Note that the system only has a
44+
* single [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the
45+
* recorded audio for each call may not be as expected.
4646
* @param context Used for querying shared preferences and accessing files via SAF. A reference is
4747
* kept in the object.
4848
* @param listener Used for sending completion notifications. The listener is called from this
4949
* thread, not the main thread.
50-
* @param call Used only for determining the output filename and is not saved.
50+
* @param call Used only for determining the output filename and is not saved. If null, then this
51+
* thread records from the mic, not from a call.
5152
*/
5253
class RecorderThread(
5354
private val context: Context,
5455
private val listener: OnRecordingCompletedListener,
55-
call: Call,
56+
call: Call?,
5657
) : Thread(RecorderThread::class.java.simpleName) {
5758
private val tag = "${RecorderThread::class.java.simpleName}/${id}"
5859
private val prefs = Preferences(context)
5960
private val isDebug = BuildConfig.DEBUG || prefs.isDebugMode
61+
private val isMic = call == null
6062

6163
// Thread state
6264
@Volatile private var isCancelled = false
@@ -67,7 +69,7 @@ class RecorderThread(
6769

6870
// Filename
6971
private val filenameLock = Object()
70-
private var pendingCallDetails: Call.Details? = null
72+
private var pendingCallDetails = call?.details
7173
private lateinit var filenameTemplate: FilenameTemplate
7274
private lateinit var filename: String
7375
private val redactions = HashMap<String, String>()
@@ -84,8 +86,6 @@ class RecorderThread(
8486
init {
8587
Log.i(tag, "Created thread for call: $call")
8688

87-
onCallDetailsChanged(call.details)
88-
8989
val savedFormat = Format.fromPreferences(prefs)
9090
format = savedFormat.first
9191
formatParam = savedFormat.second
@@ -105,6 +105,35 @@ class RecorderThread(
105105

106106
private fun redact(uri: Uri): String = redact(Uri.decode(uri.toString()))
107107

108+
/**
109+
* Update [filename] for mic recording.
110+
*
111+
* This function holds a lock on [filenameLock] until it returns.
112+
*/
113+
private fun setFilenameForMic() {
114+
synchronized(filenameLock) {
115+
redactions.clear()
116+
117+
filename = filenameTemplate.evaluate {
118+
when (it) {
119+
"date" -> {
120+
callTimestamp = ZonedDateTime.now()
121+
return@evaluate FORMATTER.format(callTimestamp)
122+
}
123+
else -> {
124+
Log.w(tag, "Unknown filename template variable: $it")
125+
}
126+
}
127+
128+
null
129+
}
130+
// AOSP's SAF automatically replaces invalid characters with underscores, but just in
131+
// case an OEM fork breaks that, do the replacement ourselves to prevent directory
132+
// traversal attacks.
133+
.replace('/', '_').trim()
134+
}
135+
}
136+
108137
/**
109138
* Update [filename] with information from [details].
110139
*
@@ -209,10 +238,18 @@ class RecorderThread(
209238
Log.i(tag, "Recording cancelled before it began")
210239
} else {
211240
val initialFilename = synchronized(filenameLock) {
212-
filenameTemplate = FilenameTemplate.load(context)
241+
val details = pendingCallDetails
242+
243+
if (details == null) {
244+
filenameTemplate = FilenameTemplate.load(context, "filename_mic")
213245

214-
onCallDetailsChanged(pendingCallDetails!!)
215-
pendingCallDetails = null
246+
setFilenameForMic()
247+
} else {
248+
filenameTemplate = FilenameTemplate.load(context, "filename")
249+
250+
onCallDetailsChanged(details)
251+
pendingCallDetails = null
252+
}
216253

217254
filename
218255
}
@@ -466,8 +503,7 @@ class RecorderThread(
466503
}
467504

468505
/**
469-
* Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
470-
* capture or encoding error occurs.
506+
* Record until [cancel] is called or an audio capture or encoding error occurs.
471507
*
472508
* [pfd] does not get closed by this method.
473509
*/
@@ -483,7 +519,11 @@ class RecorderThread(
483519
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")
484520

485521
val audioRecord = AudioRecord(
486-
MediaRecorder.AudioSource.VOICE_CALL,
522+
if (isMic) {
523+
MediaRecorder.AudioSource.MIC
524+
} else {
525+
MediaRecorder.AudioSource.VOICE_CALL
526+
},
487527
sampleRate.value.toInt(),
488528
CHANNEL_CONFIG,
489529
ENCODING,

app/src/main/res/raw/filename_template.properties

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ filename.5.prefix = _
5656

5757
################################################################################
5858

59+
# Starting time of recording.
60+
filename_mic.0.text = ${date}
61+
filename_mic.0.suffix = _mic
62+
63+
################################################################################
64+
5965
# Example: Add the call direction to the filename with a leading underscore. If
6066
# the call direction can't be determined, then add "unknown" instead.
6167
#filename.<num>.text = ${direction}

app/src/main/res/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,16 @@
4747
<string name="notification_channel_success_name">Success alerts</string>
4848
<string name="notification_channel_success_desc">Alerts for successful call recordings</string>
4949
<string name="notification_recording_in_progress">Call recording in progress</string>
50+
<string name="notification_recording_mic_in_progress">Mic recording in progress</string>
5051
<string name="notification_recording_failed">Failed to record call</string>
52+
<string name="notification_recording_mic_failed">Failed to record mic</string>
5153
<string name="notification_recording_succeeded">Successfully recorded call</string>
54+
<string name="notification_recording_mic_succeeded">Successfully recorded mic</string>
5255
<string name="notification_action_open">Open</string>
5356
<string name="notification_action_share">Share</string>
5457
<string name="notification_action_delete">Delete</string>
5558

5659
<!-- Quick settings tile -->
5760
<string name="quick_settings_label">Call recording</string>
61+
<string name="quick_settings_mic_label">Mic recording</string>
5862
</resources>

0 commit comments

Comments
 (0)