Skip to content

Commit d89371c

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 9279962 commit d89371c

File tree

4 files changed

+210
-9
lines changed

4 files changed

+210
-9
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@
4141
</intent-filter>
4242
</service>
4343

44+
<service
45+
android:name=".RecorderMicTileService"
46+
android:enabled="true"
47+
android:exported="true"
48+
android:icon="@drawable/ic_launcher_quick_settings"
49+
android:label="@string/quick_settings_mic_label"
50+
android:foregroundServiceType="microphone"
51+
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
52+
<intent-filter>
53+
<action android:name="android.service.quicksettings.action.QS_TILE" />
54+
</intent-filter>
55+
</service>
56+
4457
<service
4558
android:name=".RecorderTileService"
4659
android:enabled="true"
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}: ${thread.redact(file.uri)}")
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?.uri?.let { thread.redact(it) }}")
152+
handler.post {
153+
onThreadExited()
154+
155+
notifyFailure(errorMsg, file)
156+
}
157+
}
158+
}

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,25 @@ import android.os.Process as AndroidProcess
3636
* Captures call audio and encodes it into an output file in the user's selected directory or the
3737
* fallback/default directory.
3838
*
39-
* @constructor Create a thread for recording a call. Note that the system only has a single
40-
* [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the recorded
41-
* audio for each call may not be as expected.
39+
* @constructor Create a thread for recording a call or the mic. Note that the system only has a
40+
* single [MediaRecorder.AudioSource.VOICE_CALL] stream. If multiple calls are being recorded, the
41+
* recorded audio for each call may not be as expected.
4242
* @param context Used for querying shared preferences and accessing files via SAF. A reference is
4343
* kept in the object.
4444
* @param listener Used for sending completion notifications. The listener is called from this
4545
* thread, not the main thread.
46-
* @param call Used only for determining the output filename and is not saved.
46+
* @param call Used only for determining the output filename and is not saved. If null, then this
47+
* thread records from the mic, not from a call.
4748
*/
4849
class RecorderThread(
4950
private val context: Context,
5051
private val listener: OnRecordingCompletedListener,
51-
call: Call,
52+
call: Call?,
5253
) : Thread(RecorderThread::class.java.simpleName) {
5354
private val tag = "${RecorderThread::class.java.simpleName}/${id}"
5455
private val prefs = Preferences(context)
5556
private val isDebug = BuildConfig.DEBUG || prefs.isDebugMode
57+
private val isMic = call == null
5658

5759
// Thread state
5860
@Volatile private var isCancelled = false
@@ -78,7 +80,11 @@ class RecorderThread(
7880
init {
7981
Log.i(tag, "Created thread for call: $call")
8082

81-
onCallDetailsChanged(call.details)
83+
if (call == null) {
84+
setFilenameForMic()
85+
} else {
86+
onCallDetailsChanged(call.details)
87+
}
8288

8389
val savedFormat = Format.fromPreferences(prefs)
8490
format = savedFormat.first
@@ -99,6 +105,23 @@ class RecorderThread(
99105

100106
fun redact(uri: Uri): String = redact(Uri.decode(uri.toString()))
101107

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 = buildString {
118+
callTimestamp = ZonedDateTime.now()
119+
append(FORMATTER.format(callTimestamp))
120+
append("_mic")
121+
}
122+
}
123+
}
124+
102125
/**
103126
* Update [filename] with information from [details].
104127
*
@@ -419,8 +442,7 @@ class RecorderThread(
419442
}
420443

421444
/**
422-
* Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
423-
* capture or encoding error occurs.
445+
* Record until [cancel] is called or an audio capture or encoding error occurs.
424446
*
425447
* [pfd] does not get closed by this method.
426448
*/
@@ -436,7 +458,11 @@ class RecorderThread(
436458
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")
437459

438460
val audioRecord = AudioRecord(
439-
MediaRecorder.AudioSource.VOICE_CALL,
461+
if (isMic) {
462+
MediaRecorder.AudioSource.MIC
463+
} else {
464+
MediaRecorder.AudioSource.VOICE_CALL
465+
},
440466
sampleRate.value.toInt(),
441467
CHANNEL_CONFIG,
442468
ENCODING,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@
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

5558
<!-- Quick settings tile -->
5659
<string name="quick_settings_label">Call recording</string>
60+
<string name="quick_settings_mic_label">Mic recording</string>
5761
</resources>

0 commit comments

Comments
 (0)