Skip to content

Commit 5b68cfb

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 5b7fb88 commit 5b68cfb

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
@@ -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"
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: 35 additions & 9 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
@@ -82,7 +84,11 @@ class RecorderThread(
8284
init {
8385
Log.i(tag, "Created thread for call: $call")
8486

85-
onCallDetailsChanged(call.details)
87+
if (call == null) {
88+
setFilenameForMic()
89+
} else {
90+
onCallDetailsChanged(call.details)
91+
}
8692

8793
val savedFormat = Format.fromPreferences(prefs)
8894
format = savedFormat.first
@@ -103,6 +109,23 @@ class RecorderThread(
103109

104110
private fun redact(uri: Uri): String = redact(Uri.decode(uri.toString()))
105111

112+
/**
113+
* Update [filename] for mic recording.
114+
*
115+
* This function holds a lock on [filenameLock] until it returns.
116+
*/
117+
private fun setFilenameForMic() {
118+
synchronized(filenameLock) {
119+
redactions.clear()
120+
121+
filename = buildString {
122+
callTimestamp = ZonedDateTime.now()
123+
append(FORMATTER.format(callTimestamp))
124+
append("_mic")
125+
}
126+
}
127+
}
128+
106129
/**
107130
* Update [filename] with information from [details].
108131
*
@@ -441,8 +464,7 @@ class RecorderThread(
441464
}
442465

443466
/**
444-
* Record from [MediaRecorder.AudioSource.VOICE_CALL] until [cancel] is called or an audio
445-
* capture or encoding error occurs.
467+
* Record until [cancel] is called or an audio capture or encoding error occurs.
446468
*
447469
* [pfd] does not get closed by this method.
448470
*/
@@ -458,7 +480,11 @@ class RecorderThread(
458480
Log.d(tag, "AudioRecord minimum buffer size: $minBufSize")
459481

460482
val audioRecord = AudioRecord(
461-
MediaRecorder.AudioSource.VOICE_CALL,
483+
if (isMic) {
484+
MediaRecorder.AudioSource.MIC
485+
} else {
486+
MediaRecorder.AudioSource.VOICE_CALL
487+
},
462488
sampleRate.value.toInt(),
463489
CHANNEL_CONFIG,
464490
ENCODING,

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)