Skip to content

Commit

Permalink
Add support for direct boot
Browse files Browse the repository at this point in the history
This allows BCR to record calls prior to the device being initially
unlocked after a reboot. In the BFU (before unlock state), recordings
are temporarily stored in an internal device-protected storage
directory. If the call completes before the initial unlock, then a
migration service that automatically runs after unlock will move the
files to the output directory. If the device is unlocked while the call
is still ongoing, then the recording will be moved to the output
directory at the end of the call.

There are some limitations, like not being able to look up contacts or
the call log, but most of BCR's will basically work as expected.

Signed-off-by: Andrew Gunnerson <[email protected]>
  • Loading branch information
chenxiaolong committed Aug 1, 2024
1 parent 6c8e833 commit c6ed035
Show file tree
Hide file tree
Showing 17 changed files with 573 additions and 88 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ BCR is a simple Android call recording app for rooted devices or devices running
* FLAC - Lossless, larger files
* WAV/PCM - Lossless, largest files, least CPU usage
* Supports Android's Storage Access Framework (can record to SD cards, USB devices, etc.)
* Direct boot aware (records calls prior to first unlock after a reboot)
* Per-contact auto-record rules
* Quick settings toggle
* Material You dynamic theming
Expand Down Expand Up @@ -82,6 +83,17 @@ When BCR is enabled, avoid using the the dialer's built-in call recorder at all.
If you live in a jurisdiction where two-party consent is required, you are responsible for informing the other party that the call is being recorded. If needed, auto-record rules can be used to discard recordings by default. However, note that if you choose to preserve the recording during the middle of the call, the recording will contain full call, not just the portion after the other party consented.
## Direct boot
BCR is direct boot aware, meaning that it's capable of running and recording calls before the device is initially unlocked following a reboot. In this state, most of BCR's functionality will still work, aside from features that require the contact list or call log. In practice, this means:
* If auto-record rules are set up, they are mostly ignored. All contacts are treated as unknown numbers.
* The output filename, if using the default template, will only contain the caller ID, not the contact name or call log name.
However, if the device is unlocked before the call ends, then none of these limitations apply.
Note that the output directory is not available before the device is unlocked for the first time. Recordings made while in the state are stored in an internal directory that's not accessible by the user. After the device is unlocked, BCR will move the files to the output directory. This may take a few moments to complete.

## Permissions

* `CAPTURE_AUDIO_OUTPUT` (**automatically granted by system app permissions**)
Expand All @@ -100,6 +112,8 @@ If you live in a jurisdiction where two-party consent is required, you are respo
* This is also required to show the correct phone number when using call redirection apps.
* `READ_CONTACTS` (**optional**)
* If allowed, the contact name can be added to the output filename. It also allows auto-record rules to be set per contact.
* `RECEIVE_BOOT_COMPLETED`, `FOREGROUND_SERVICE_SPECIAL_USE` (**automatically granted at install time**)
* Needed to automatically move recordings made before the initial device unlock to the output directory.
* `READ_PHONE_STATE` (**optional**)
* If allowed, the SIM slot for devices with multiple active SIMs is added to the output filename.
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (**optional**)
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />

Expand Down Expand Up @@ -49,11 +51,26 @@
android:name=".NotificationActionService"
android:exported="false" />

<receiver
android:name=".DirectBootMigrationReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

<service
android:name=".DirectBootMigrationService"
android:exported="false"
android:foregroundServiceType="specialUse" />

<service
android:name=".RecorderInCallService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="microphone"
android:directBootAware="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<intent-filter>
<action android:name="android.telecom.InCallService" />
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.chiller3.bcr

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent

class DirectBootMigrationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) {
return
}

context.startForegroundService(Intent(context, DirectBootMigrationService::class.java))
}
}
246 changes: 246 additions & 0 deletions app/src/main/java/com/chiller3/bcr/DirectBootMigrationService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package com.chiller3.bcr

import android.app.Service
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile
import com.chiller3.bcr.format.Format
import com.chiller3.bcr.output.OutputDirUtils
import com.chiller3.bcr.output.OutputFile
import com.chiller3.bcr.output.OutputFilenameGenerator
import java.io.File

class DirectBootMigrationService : Service() {
companion object {
private val TAG = DirectBootMigrationService::class.java.simpleName

private fun isKnownExtension(extension: String): Boolean {
return extension == "log" || MimeTypeMap.getSingleton().hasExtension(extension)
}

private fun splitKnownExtension(name: String): Pair<String, String> {
val dot = name.lastIndexOf('.')
if (dot > 0) {
val extension = name.substring(dot + 1)
if (isKnownExtension(extension)) {
return name.substring(0, dot) to extension
}
}

return name to ""
}

private data class MimeType(val isAudio: Boolean, val type: String)

private val FALLBACK_MIME_TYPE = MimeType(false, "application/octet-stream")

/**
* Get the MIME type based on the extension if it is known.
*
* We do not use [MimeTypeMap.getMimeTypeFromExtension] because the mime type <-> extension
* mapping is not 1:1. When showing notifications for moved files, we want to use the same
* MIME type that we would have used for the initial file creation.
*/
private fun mimeTypeForExtension(extension: String): MimeType? {
val knownMimeTypes = sequence {
yieldAll(Format.all.asSequence().map { MimeType(true, it.mimeTypeContainer) })
yield(MimeType(false, RecorderThread.MIME_LOGCAT))
yield(MimeType(false, RecorderThread.MIME_METADATA))
}

return knownMimeTypes.find {
MimeTypeMap.getSingleton().getExtensionFromMimeType(it.type) == extension
}
}
}

private val handler = Handler(Looper.getMainLooper())
private lateinit var prefs: Preferences
private lateinit var notifications: Notifications
private lateinit var outputFilenameGenerator: OutputFilenameGenerator
private val redactor = object : OutputDirUtils.Redactor {
override fun redact(msg: String): String = OutputFilenameGenerator.redactTruncate(msg)
}
private lateinit var dirUtils: OutputDirUtils
private var ranOnce = false
private val thread = Thread {
try {
migrateFiles()
} catch (e: Exception) {
Log.w(TAG, "Failed to migrate files", e)
onFailure(e.localizedMessage)
} finally {
handler.post {
tryStop()
}
}
}

override fun onCreate() {
super.onCreate()

prefs = Preferences(this)
notifications = Notifications(this)
outputFilenameGenerator = OutputFilenameGenerator(this)
dirUtils = OutputDirUtils(this, redactor)
}

override fun onBind(intent: Intent?): IBinder? = null

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!ranOnce) {
ranOnce = true
startThread()
} else {
tryStop()
}

return START_NOT_STICKY
}

private fun startThread() {
Log.i(TAG, "Starting direct boot file migration")

val notification = notifications.createPersistentNotification(
R.string.notification_direct_boot_migration_in_progress,
null,
emptyList(),
)
startForeground(prefs.nextNotificationId, notification)

thread.start()
}

private fun tryStop() {
if (!thread.isAlive) {
Log.d(TAG, "Stopping service")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}

private fun migrateFiles() {
val sourceDir = prefs.directBootCompletedDir

val filesToMove = sourceDir.walkTopDown().filter { it.isFile }.toList()
Log.i(TAG, "${filesToMove.size} files to migrate")

data class FileInfo(
val file: File,
val path: List<String>,
val mime: MimeType,
)

// Group the files by prefix to form logical groups. If the group has an audio file, then
// we'll show a notification similar to when a recording normally completes so that the user
// can easily open, share, or delete the file.
val byPrefix = mutableMapOf<String?, ArrayDeque<FileInfo>>()
val ungrouped = ArrayDeque<FileInfo>()

for (file in filesToMove) {
// This is used for actual file creation with SAF.
val (baseName, extension) = splitKnownExtension(file.name)
val mimeType = mimeTypeForExtension(extension) ?: FALLBACK_MIME_TYPE

// The name with all known extensions removed is only used for grouping.
var prefixName = baseName
while (true) {
val (name, ext) = splitKnownExtension(prefixName)
if (ext.isEmpty()) {
break
} else {
prefixName = name
}
}

val relParent = file.parentFile!!.relativeTo(sourceDir)
val relBasePath = File(relParent, baseName)
val prefix = File(relParent, prefixName)
val group = byPrefix.getOrPut(prefix.toString()) { ArrayDeque() }
val fileInfo = FileInfo(
file,
OutputFilenameGenerator.splitPath(relBasePath.toString()),
mimeType,
)

if (mimeType.isAudio) {
group.addFirst(fileInfo)
} else {
group.addLast(fileInfo)
}
}

// Get rid of groups that have no audio.
val byPrefixIterator = byPrefix.iterator()
while (byPrefixIterator.hasNext()) {
val (_, files) = byPrefixIterator.next()
if (!files.first().mime.isAudio) {
ungrouped.addAll(files)
byPrefixIterator.remove()
}
}

if (ungrouped.isNotEmpty()) {
byPrefix[null] = ungrouped
}

var succeeded = 0
var failed = 0

for ((prefix, group) in byPrefix) {
var notifySuccess = prefix != null
val groupFiles = ArrayDeque<OutputFile>()

for (fileInfo in group) {
val newFile = dirUtils.tryMoveToOutputDir(
DocumentFile.fromFile(fileInfo.file),
fileInfo.path,
fileInfo.mime.type,
)

if (newFile != null) {
groupFiles.add(
OutputFile(
newFile.uri,
redactor.redact(newFile.uri),
fileInfo.mime.type,
)
)
succeeded += 1
} else {
notifySuccess = false
failed += 1
}
}

if (notifySuccess) {
// This is not perfect, but it's good enough. A file may exist even though the
// recording failed. In this scenario, the user would see the failure notification
// from the recorder thread and a success notification from us moving the file.
onSuccess(groupFiles.removeFirst(), groupFiles)
}
}

if (failed != 0) {
onFailure(getString(R.string.notification_direct_boot_migration_error))
}

Log.i(TAG, "$succeeded succeeded, $failed failed")
}

private fun onSuccess(file: OutputFile, additionalFiles: List<OutputFile>) {
handler.post {
notifications.notifyRecordingSuccess(file, additionalFiles)
}
}

private fun onFailure(errorMsg: String?) {
handler.post {
notifications.notifyMigrationFailure(errorMsg)
}
}
}
Loading

0 comments on commit c6ed035

Please sign in to comment.