Skip to content

Commit

Permalink
Add quick settings tile for recording from microphone
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
chenxiaolong authored and PatrykMis committed Mar 18, 2023
1 parent 0b14172 commit b23282b
Show file tree
Hide file tree
Showing 6 changed files with 358 additions and 39 deletions.
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@
</intent-filter>
</service>

<service
android:name=".RecorderMicTileService"
android:enabled="true"
android:exported="true"
android:icon="@drawable/ic_launcher_quick_settings"
android:label="@string/quick_settings_mic_label"
android:foregroundServiceType="microphone"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>

<service
android:name=".RecorderTileService"
android:enabled="true"
Expand Down
16 changes: 8 additions & 8 deletions app/src/main/java/com/chiller3/bcr/FilenameTemplate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ import java.util.*
import java.util.regex.Matcher
import java.util.regex.Pattern

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

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

while (true) {
val index = components.size
val text = props.getProperty("filename.$index.text") ?: break
val default = props.getProperty("filename.$index.default")
val prefix = props.getProperty("filename.$index.prefix")
val suffix = props.getProperty("filename.$index.suffix")
val text = props.getProperty("$key.$index.text") ?: break
val default = props.getProperty("$key.$index.default")
val prefix = props.getProperty("$key.$index.prefix")
val suffix = props.getProperty("$key.$index.suffix")

components.add(Component(text, default, prefix, suffix))
}
Expand Down Expand Up @@ -101,7 +101,7 @@ class FilenameTemplate private constructor(props: Properties) {
}
}.isNotEmpty()

fun load(context: Context, allowCustom: Boolean): FilenameTemplate {
fun load(context: Context, key: String, allowCustom: Boolean): FilenameTemplate {
val props = Properties()

if (allowCustom) {
Expand All @@ -120,7 +120,7 @@ class FilenameTemplate private constructor(props: Properties) {

context.contentResolver.openInputStream(templateFile.uri)?.use {
props.load(it)
return FilenameTemplate(props)
return FilenameTemplate(props, key)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load custom filename template", e)
Expand All @@ -134,7 +134,7 @@ class FilenameTemplate private constructor(props: Properties) {

context.resources.openRawResource(R.raw.filename_template).use {
props.load(it)
return FilenameTemplate(props)
return FilenameTemplate(props, key)
}
}
}
Expand Down
240 changes: 240 additions & 0 deletions app/src/main/java/com/chiller3/bcr/RecorderMicTileService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package com.chiller3.bcr

import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import kotlin.random.Random

class RecorderMicTileService : TileService(), RecorderThread.OnRecordingCompletedListener {
companion object {
private val TAG = RecorderMicTileService::class.java.simpleName

private val ACTION_PAUSE = "${RecorderMicTileService::class.java.canonicalName}.pause"
private val ACTION_RESUME = "${RecorderMicTileService::class.java.canonicalName}.resume"
private const val EXTRA_TOKEN = "token"
}

private lateinit var notifications: Notifications
private val handler = Handler(Looper.getMainLooper())

private var recorder: RecorderThread? = null

private var tileIsListening = false

/**
* Token value for all intents received by this instance of the service.
*
* For the pause/resume functionality, we cannot use a bound service because [TileService]
* uses its own non-extensible [onBind] implementation. So instead, we rely on [onStartCommand].
* However, because this service is required to be exported, the intents could potentially come
* from third party apps and we don't want those interfering with the recordings.
*/
private val token = Random.Default.nextBytes(128)

private fun createBaseIntent(): Intent =
Intent(this, RecorderMicTileService::class.java).apply {
putExtra(EXTRA_TOKEN, token)
}

private fun createPauseIntent(): Intent =
createBaseIntent().apply {
action = ACTION_PAUSE
}

private fun createResumeIntent(): Intent =
createBaseIntent().apply {
action = ACTION_RESUME
}

override fun onCreate() {
super.onCreate()

notifications = Notifications(this)
}

/** Handle intents triggered from notification actions for pausing and resuming. */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
val receivedToken = intent?.getByteArrayExtra(EXTRA_TOKEN)
if (intent?.action != null && !receivedToken.contentEquals(token)) {
throw IllegalArgumentException("Invalid token")
}

when (val action = intent?.action) {
ACTION_PAUSE, ACTION_RESUME -> {
recorder!!.isPaused = action == ACTION_PAUSE
updateForegroundState()
}
null -> {
// Ignore. Hack to keep service alive longer than the tile lifecycle.
}
else -> throw IllegalArgumentException("Invalid action: $action")
}
} catch (e: Exception) {
Log.w(TAG, "Failed to handle intent: $intent", e)
}

// Kill service if the only reason it is started is due to the intent
if (recorder == null) {
stopSelf(startId)
}
return START_NOT_STICKY
}

override fun onStartListening() {
super.onStartListening()

tileIsListening = true

refreshTileState()
}

override fun onStopListening() {
super.onStopListening()

tileIsListening = false
}

override fun onClick() {
super.onClick()

if (!Permissions.haveRequired(this)) {
val intent = Intent(this, SettingsActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivityAndCollapse(intent)
} else if (recorder == null) {
startRecording()
} else {
requestStopRecording()
}

refreshTileState()
}

private fun refreshTileState() {
val tile = qsTile

// Tile.STATE_UNAVAILABLE is intentionally not used when permissions haven't been granted.
// Clicking the tile in that state does not invoke the click handler, so it wouldn't be
// possible to launch SettingsActivity to grant the permissions.
if (Permissions.haveRequired(this) && recorder != null) {
tile.state = Tile.STATE_ACTIVE
} else {
tile.state = Tile.STATE_INACTIVE
}

tile.updateTile()
}

/**
* Start the [RecorderThread].
*
* If the required permissions aren't granted, then the service will stop.
*
* This function is idempotent.
*/
private fun startRecording() {
if (recorder == null) {
recorder = try {
RecorderThread(this, this, null)
} catch (e: Exception) {
notifyFailure(e.message, null)
throw e
}

// Ensure the service lives past the tile lifecycle
startForegroundService(Intent(this, this::class.java))
updateForegroundState()
recorder!!.start()
}
}

/**
* Request the cancellation of the [RecorderThread].
*
* The foreground notification stays alive until the [RecorderThread] exits and reports its
* status. The thread may exit before this function is called if an error occurs during
* recording.
*
* This function is idempotent.
*/
private fun requestStopRecording() {
recorder?.cancel()
}

private fun updateForegroundState() {
if (recorder == null) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
if (recorder!!.isPaused) {
startForeground(1, notifications.createPersistentNotification(
R.string.notification_recording_mic_paused,
R.drawable.ic_launcher_quick_settings,
R.string.notification_action_resume,
createResumeIntent(),
))
} else {
startForeground(1, notifications.createPersistentNotification(
R.string.notification_recording_mic_in_progress,
R.drawable.ic_launcher_quick_settings,
R.string.notification_action_pause,
createPauseIntent(),
))
}
}
}

private fun notifySuccess(file: OutputFile) {
notifications.notifySuccess(
R.string.notification_recording_mic_succeeded,
R.drawable.ic_launcher_quick_settings,
file,
)
}

private fun notifyFailure(errorMsg: String?, file: OutputFile?) {
notifications.notifyFailure(
R.string.notification_recording_mic_failed,
R.drawable.ic_launcher_quick_settings,
errorMsg,
file,
)
}

private fun onThreadExited() {
recorder = null

if (tileIsListening) {
refreshTileState()
}

// The service no longer needs to live past the tile lifecycle
updateForegroundState()
stopSelf()
}

override fun onRecordingCompleted(thread: RecorderThread, file: OutputFile?) {
Log.i(TAG, "Recording completed: ${thread.id}: ${file?.redacted}")
handler.post {
onThreadExited()

// If the recording was initially paused and the user never resumed it, there's no
// output file, so nothing needs to be shown.
if (file != null) {
notifySuccess(file)
}
}
}

override fun onRecordingFailed(thread: RecorderThread, errorMsg: String?, file: OutputFile?) {
Log.w(TAG, "Recording failed: ${thread.id}: ${file?.redacted}")
handler.post {
onThreadExited()

notifyFailure(errorMsg, file)
}
}
}
Loading

0 comments on commit b23282b

Please sign in to comment.