From 87a59e4dc3a77a21c37b917d50f21db02219cb77 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 31 Jul 2024 21:07:34 -0400 Subject: [PATCH] Add support for direct boot 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 --- README.md | 14 ++ app/src/main/AndroidManifest.xml | 17 ++ .../bcr/DirectBootMigrationReceiver.kt | 15 ++ .../bcr/DirectBootMigrationService.kt | 238 ++++++++++++++++++ .../java/com/chiller3/bcr/Notifications.kt | 95 +++++-- .../main/java/com/chiller3/bcr/Preferences.kt | 93 ++++++- .../com/chiller3/bcr/RecorderApplication.kt | 3 + .../com/chiller3/bcr/RecorderInCallService.kt | 19 +- .../java/com/chiller3/bcr/RecorderThread.kt | 17 +- .../com/chiller3/bcr/RecorderTileService.kt | 7 +- .../bcr/extension/DocumentFileExtensions.kt | 7 + .../chiller3/bcr/extension/UriExtensions.kt | 23 ++ .../com/chiller3/bcr/output/OutputDirUtils.kt | 45 ++-- .../bcr/output/OutputFilenameGenerator.kt | 2 +- .../chiller3/bcr/settings/SettingsFragment.kt | 28 ++- app/src/main/res/values/strings.xml | 11 + app/src/main/res/xml/root_preferences.xml | 19 ++ 17 files changed, 565 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt create mode 100644 app/src/main/java/com/chiller3/bcr/DirectBootMigrationService.kt diff --git a/README.md b/README.md index 81dfd4594..d43632c57 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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**) @@ -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**) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 84cb4dd60..e73be8244 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,9 +19,11 @@ + + @@ -49,11 +51,26 @@ android:name=".NotificationActionService" android:exported="false" /> + + + + + + + + diff --git a/app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt b/app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt new file mode 100644 index 000000000..02ed4c85c --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/DirectBootMigrationReceiver.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/DirectBootMigrationService.kt b/app/src/main/java/com/chiller3/bcr/DirectBootMigrationService.kt new file mode 100644 index 000000000..66f98931a --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/DirectBootMigrationService.kt @@ -0,0 +1,238 @@ +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 { + 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 + + 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 { + stopSelf(startId) + } + + 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 { + try { + migrateFiles() + } catch (e: Exception) { + Log.w(TAG, "Failed to migrate files", e) + onFailure(e.localizedMessage) + } finally { + handler.post { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + }.start() + } + + 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, + 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>() + val ungrouped = ArrayDeque() + + 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() + + 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) { + handler.post { + notifications.notifyRecordingSuccess(file, additionalFiles) + } + } + + private fun onFailure(errorMsg: String?) { + handler.post { + notifications.notifyMigrationFailure(errorMsg) + } + } +} diff --git a/app/src/main/java/com/chiller3/bcr/Notifications.kt b/app/src/main/java/com/chiller3/bcr/Notifications.kt index 7c79f7ee6..0a32ce047 100644 --- a/app/src/main/java/com/chiller3/bcr/Notifications.kt +++ b/app/src/main/java/com/chiller3/bcr/Notifications.kt @@ -5,6 +5,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.res.Resources @@ -13,12 +14,11 @@ import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.util.Log -import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.net.toFile import com.chiller3.bcr.extension.formattedString import com.chiller3.bcr.output.OutputFile import com.chiller3.bcr.settings.SettingsActivity -import java.util.* class Notifications( private val context: Context, @@ -128,7 +128,6 @@ class Notifications( fun createPersistentNotification( @StringRes titleResId: Int, message: String?, - @DrawableRes iconResId: Int, actions: List>, ): Notification { val notificationIntent = Intent(context, SettingsActivity::class.java) @@ -142,7 +141,7 @@ class Notifications( setContentText(message) style = Notification.BigTextStyle().bigText(message) } - setSmallIcon(iconResId) + setSmallIcon(R.drawable.ic_launcher_quick_settings) setContentIntent(pendingIntent) setOngoing(true) setOnlyAlertOnce(true) @@ -172,20 +171,32 @@ class Notifications( } } + /** Check whether the file lives on device-protected storage. */ + private fun isDeviceProtectedStorage(outputFile: OutputFile): Boolean { + if (outputFile.uri.scheme != ContentResolver.SCHEME_FILE) { + return false + } + + val file = outputFile.uri.toFile() + + return file.relativeToOrNull(prefs.directBootInProgressDir) != null + && file.relativeToOrNull(prefs.directBootCompletedDir) != null + } + /** - * Send an alert notification with the given [title] and [icon]. + * Send a recording alert notification with the given [title]. * - * * If [errorMsg] is not null, then it is appended to the text with a black line before it. + * * If [errorMsg] is not null, then it is prepended to the text with a blank line after it. * * If [file] is not null, the human-readable URI path is appended to the text with a blank * line before it if needed. In addition, three actions, open/share/delete, are added to the * notification. The delete action dismisses the notification, but open and share do not. * Clicking on the notification itself will behave like the open action, except the - * notification will be dismissed. + * notification will be dismissed. However, if the file refers to a file on device-protected + * storage, then all actions are removed since the file is not accessible to the user. */ - private fun sendAlertNotification( + private fun sendRecordingNotification( channel: String, @StringRes title: Int, - @DrawableRes icon: Int, errorMsg: String?, file: OutputFile?, additionalFiles: List, @@ -199,7 +210,7 @@ class Notifications( append(errorMsgTrimmed) } if (file != null) { - if (!isEmpty()) { + if (isNotEmpty()) { append("\n\n") } append(file.uri.formattedString) @@ -211,9 +222,9 @@ class Notifications( setContentText(text) style = Notification.BigTextStyle() } - setSmallIcon(icon) + setSmallIcon(R.drawable.ic_launcher_quick_settings) - if (file != null) { + if (file != null && !isDeviceProtectedStorage(file)) { // It is not possible to grant access to SAF URIs to other applications val wrappedUri = RecorderProvider.fromOrigUri(file.uri) @@ -282,40 +293,74 @@ class Notifications( } /** - * Send a success alert notification. + * Send a recording success alert notification. * * This will explicitly vibrate the device if the user enabled vibration for * [CHANNEL_ID_SUCCESS]. This is necessary because Android itself will not vibrate for a * notification during a phone call. + * + * If [file] lives on device protected storage, then the notification will not be sent because + * the user cannot act on it in any meaningful way. */ - fun notifySuccess( - @StringRes title: Int, - @DrawableRes icon: Int, - file: OutputFile, - additionalFiles: List, - ) { - sendAlertNotification(CHANNEL_ID_SUCCESS, title, icon, null, file, additionalFiles) + fun notifyRecordingSuccess(file: OutputFile, additionalFiles: List) { + if (isDeviceProtectedStorage(file)) { + return + } + sendRecordingNotification( + CHANNEL_ID_SUCCESS, + R.string.notification_recording_succeeded, + null, + file, + additionalFiles, + ) vibrateIfEnabled(CHANNEL_ID_SUCCESS) } /** - * Send a failure alert notification. + * Send a recording failure alert notification. * * This will explicitly vibrate the device if the user enabled vibration for * [CHANNEL_ID_FAILURE]. This is necessary because Android itself will not vibrate for a * notification during a phone call. + * + * If [file] lives on device protected storage, the notification will still be shown, but + * without any actions. */ - fun notifyFailure( - @StringRes title: Int, - @DrawableRes icon: Int, + fun notifyRecordingFailure( errorMsg: String?, file: OutputFile?, additionalFiles: List, ) { - sendAlertNotification(CHANNEL_ID_FAILURE, title, icon, errorMsg, file, additionalFiles) + sendRecordingNotification( + CHANNEL_ID_FAILURE, + R.string.notification_recording_failed, + errorMsg, + file, + additionalFiles, + ) vibrateIfEnabled(CHANNEL_ID_FAILURE) } + /** Send a direct boot file migration failure alert notification. */ + fun notifyMigrationFailure(errorMsg: String?) { + val notificationId = prefs.nextNotificationId + + val notification = Notification.Builder(context, CHANNEL_ID_FAILURE).run { + val text = errorMsg?.trim() ?: "" + + setContentTitle(context.getString(R.string.notification_direct_boot_migration_failed)) + if (text.isNotBlank()) { + setContentText(text) + style = Notification.BigTextStyle() + } + setSmallIcon(R.drawable.ic_launcher_quick_settings) + + build() + } + + notificationManager.notify(notificationId, notification) + } + /** Dismiss all alert (non-persistent) notifications. */ fun dismissAll() { for (notification in notificationManager.activeNotifications) { diff --git a/app/src/main/java/com/chiller3/bcr/Preferences.kt b/app/src/main/java/com/chiller3/bcr/Preferences.kt index 93d4fb8e1..e3d707b2c 100644 --- a/app/src/main/java/com/chiller3/bcr/Preferences.kt +++ b/app/src/main/java/com/chiller3/bcr/Preferences.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Environment +import android.os.UserManager import android.provider.DocumentsContract import android.util.Log import androidx.core.content.edit @@ -16,10 +17,11 @@ import com.chiller3.bcr.rule.RecordRule import com.chiller3.bcr.template.Template import java.io.File -class Preferences(private val context: Context) { +class Preferences(initialContext: Context) { companion object { private val TAG = Preferences::class.java.simpleName + const val CATEGORY_DEBUG = "debug" const val CATEGORY_RULES = "rules" const val PREF_CALL_RECORDING = "call_recording" @@ -28,10 +30,12 @@ class Preferences(private val context: Context) { const val PREF_FILENAME_TEMPLATE = "filename_template" const val PREF_OUTPUT_FORMAT = "output_format" const val PREF_INHIBIT_BATT_OPT = "inhibit_batt_opt" - const val PREF_WRITE_METADATA = "write_metadata" - const val PREF_RECORD_TELECOM_APPS = "record_telecom_apps" - const val PREF_RECORD_DIALING_STATE = "record_dialing_state" + private const val PREF_WRITE_METADATA = "write_metadata" + private const val PREF_RECORD_TELECOM_APPS = "record_telecom_apps" + private const val PREF_RECORD_DIALING_STATE = "record_dialing_state" const val PREF_VERSION = "version" + private const val PREF_FORCE_DIRECT_BOOT = "force_direct_boot" + const val PREF_MIGRATE_DIRECT_BOOT = "migrate_direct_boot" const val PREF_ADD_RULE = "add_rule" const val PREF_RULE_PREFIX = "rule_" @@ -45,6 +49,7 @@ class Preferences(private val context: Context) { const val PREF_OUTPUT_RETENTION = "output_retention" const val PREF_SAMPLE_RATE = "sample_rate" private const val PREF_NEXT_NOTIFICATION_ID = "next_notification_id" + private const val PREF_ALREADY_MIGRATED = "already_migrated"; // Defaults val DEFAULT_FILENAME_TEMPLATE = Template( @@ -63,8 +68,43 @@ class Preferences(private val context: Context) { key == PREF_FORMAT_NAME || key.startsWith(PREF_FORMAT_PARAM_PREFIX) || key.startsWith(PREF_FORMAT_SAMPLE_RATE_PREFIX) + + fun migrateToDeviceProtectedStorage(context: Context) { + if (context.isDeviceProtectedStorage) { + Log.w(TAG, "Cannot migrate preferences in BFU state") + return + } + + val deviceContext = context.createDeviceProtectedStorageContext() + var devicePrefs = PreferenceManager.getDefaultSharedPreferences(deviceContext) + + if (devicePrefs.getBoolean(PREF_ALREADY_MIGRATED, false)) { + Log.i(TAG, "Already migrated preferences to device protected storage") + return + } + + Log.i(TAG, "Migrating preferences to device protected storage") + + // getDefaultSharedPreferencesName() is not public, but realistically, Android can't + // ever change the default shared preferences name without breaking nearly every app. + val sharedPreferencesName = context.packageName + "_preferences" + + // This returns true if the shared preferences didn't exist. + if (!deviceContext.moveSharedPreferencesFrom(context, sharedPreferencesName)) { + Log.e(TAG, "Failed to migrate preferences to device protected storage") + } + + devicePrefs = PreferenceManager.getDefaultSharedPreferences(deviceContext) + devicePrefs.edit { putBoolean(PREF_ALREADY_MIGRATED, true) } + } } + private val context = if (initialContext.isDeviceProtectedStorage) { + initialContext + } else { + initialContext.createDeviceProtectedStorageContext() + } + private val userManager = context.getSystemService(UserManager::class.java) internal val prefs = PreferenceManager.getDefaultSharedPreferences(context) /** @@ -104,15 +144,36 @@ class Preferences(private val context: Context) { } } + /** Whether to show debug preferences and enable creation of debug logs for all calls. */ var isDebugMode: Boolean get() = BuildConfig.FORCE_DEBUG_MODE || prefs.getBoolean(PREF_DEBUG_MODE, false) set(enabled) = prefs.edit { putBoolean(PREF_DEBUG_MODE, enabled) } + /** Whether to output to direct boot directories even if the device has been unlocked once. */ + private var forceDirectBoot: Boolean + get() = prefs.getBoolean(PREF_FORCE_DIRECT_BOOT, false) + set(enabled) = prefs.edit { putBoolean(PREF_FORCE_DIRECT_BOOT, enabled) } + + /** Whether we're running in direct boot mode. */ + private val isDirectBoot: Boolean + get() = !userManager.isUserUnlocked || forceDirectBoot + + /** Default output directory in the BFU state. */ + val directBootInProgressDir: File = File(context.filesDir, "in_progress") + + /** Target output directory in the BFU state. */ + val directBootCompletedDir: File = File(context.filesDir, "completed") + /** * Get the default output directory. The directory should always be writable and is suitable for - * use as a fallback. + * use as a fallback. In the BFU state, this returns the internal app directory backed by + * device-protected storage, which is not accessible by the user. */ - val defaultOutputDir: File = context.getExternalFilesDir(null)!! + val defaultOutputDir: File = if (isDirectBoot) { + directBootInProgressDir + } else { + context.getExternalFilesDir(null)!! + } /** * The user-specified output directory. @@ -121,10 +182,28 @@ class Preferences(private val context: Context) { * persisted URI permissions for the old URI will be revoked and persisted write permissions * for the new URI will be requested. If the old and new URI are the same, nothing is done. If * persisting permissions for the new URI fails, the saved output directory is not changed. + * + * In the BFU state, this always returns null to ensure that nothing tries to move files into + * credential-protected storage. */ var outputDir: Uri? - get() = prefs.getString(PREF_OUTPUT_DIR, null)?.let { Uri.parse(it) } + get() = if (isDirectBoot) { + // We initially record to the in-progress directory and then atomically move them to the + // completed directory afterwards. This ensures that the recorder thread won't ever race + // with the direct boot migration service. Any completed recordings are guaranteed to be + // in the completed directory and will be moved by the service. Any recordings that are + // in-progress after the user unlocks for the first time will be moved to the user's + // output directory by the recorder thread since isUserUnlocked will be true by that + // point. + Uri.fromFile(directBootCompletedDir) + } else { + prefs.getString(PREF_OUTPUT_DIR, null)?.let { Uri.parse(it) } + } set(uri) { + if (isDirectBoot) { + throw IllegalStateException("Changing output directory while in direct boot") + } + val oldUri = outputDir if (oldUri == uri) { // URI is the same as before or both are null diff --git a/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt b/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt index 8b9aefa52..f03cc33f2 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderApplication.kt @@ -40,6 +40,9 @@ class RecorderApplication : Application() { } } + // Move preferences to device-protected storage for direct boot support. + Preferences.migrateToDeviceProtectedStorage(this) + // Migrate legacy preferences. val prefs = Preferences(this) prefs.migrateSampleRate() diff --git a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt index 7f3314d60..120889e6e 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderInCallService.kt @@ -9,7 +9,6 @@ import android.os.Looper import android.telecom.Call import android.telecom.InCallService import android.util.Log -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.chiller3.bcr.output.OutputFile import kotlin.random.Random @@ -50,7 +49,6 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet private data class NotificationState( @StringRes val titleResId: Int, val message: String?, - @DrawableRes val iconResId: Int, // We don't store the intents because Intent does not override equals() val actionsResIds: List, ) @@ -386,7 +384,6 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet val state = NotificationState( titleResId, message.toString(), - R.drawable.ic_launcher_quick_settings, actionResIds, ) if (state == allNotificationIds[notificationId]) { @@ -397,7 +394,6 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet val notification = notifications.createPersistentNotification( state.titleResId, state.message, - state.iconResId, state.actionsResIds.zip(actionIntents), ) @@ -415,12 +411,7 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet } private fun notifySuccess(file: OutputFile, additionalFiles: List) { - notifications.notifySuccess( - R.string.notification_recording_succeeded, - R.drawable.ic_launcher_quick_settings, - file, - additionalFiles, - ) + notifications.notifyRecordingSuccess(file, additionalFiles) } private fun notifyFailure( @@ -428,13 +419,7 @@ class RecorderInCallService : InCallService(), RecorderThread.OnRecordingComplet file: OutputFile?, additionalFiles: List, ) { - notifications.notifyFailure( - R.string.notification_recording_failed, - R.drawable.ic_launcher_quick_settings, - errorMsg, - file, - additionalFiles, - ) + notifications.notifyRecordingFailure(errorMsg, file, additionalFiles) } private fun onRecorderExited(recorder: RecorderThread) { diff --git a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt index 40e7dc0ca..17d6a5fb8 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderThread.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderThread.kt @@ -16,6 +16,7 @@ import com.chiller3.bcr.extension.deleteIfEmptyDir import com.chiller3.bcr.extension.frameSizeInBytesCompat import com.chiller3.bcr.extension.listFilesWithPathsRecursively import com.chiller3.bcr.extension.phoneNumber +import com.chiller3.bcr.extension.toDocumentFile import com.chiller3.bcr.format.Encoder import com.chiller3.bcr.format.Format import com.chiller3.bcr.format.NoParamInfo @@ -413,10 +414,15 @@ class RecorderThread( } val metadataBytes = metadataJson.toString(4).toByteArray() - val metadataFile = dirUtils.createFileInOutputDir(path, MIME_METADATA) + // Always create in the default directory and then move to ensure that we don't race + // with the direct boot file migration process. + var metadataFile = dirUtils.createFileInDefaultDir(path, MIME_METADATA) dirUtils.openFile(metadataFile, true).use { writeFully(it.fileDescriptor, metadataBytes, 0, metadataBytes.size) } + dirUtils.tryMoveToOutputDir(metadataFile, path, MIME_METADATA)?.let { + metadataFile = it + } return OutputFile( metadataFile.uri, @@ -437,10 +443,7 @@ class RecorderThread( * files are ignored. */ private fun processRetention() { - val directory = prefs.outputDir?.let { - // Only returns null on API <21 - DocumentFile.fromTreeUri(context, it)!! - } ?: DocumentFile.fromFile(prefs.defaultOutputDir) + val directory = prefs.outputDirOrDefault.toDocumentFile(context) val retention = when (val r = Retention.fromPreferences(prefs)) { NoRetention -> { @@ -681,8 +684,8 @@ class RecorderThread( private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT - private const val MIME_LOGCAT = "text/plain" - private const val MIME_METADATA = "application/json" + const val MIME_LOGCAT = "text/plain" + const val MIME_METADATA = "application/json" } private data class RecordingInfo( diff --git a/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt b/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt index 4038be07e..1ffe62a98 100644 --- a/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt +++ b/app/src/main/java/com/chiller3/bcr/RecorderTileService.kt @@ -8,7 +8,6 @@ import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log -import androidx.preference.PreferenceManager import com.chiller3.bcr.settings.SettingsActivity class RecorderTileService : TileService(), SharedPreferences.OnSharedPreferenceChangeListener { @@ -22,16 +21,14 @@ class RecorderTileService : TileService(), SharedPreferences.OnSharedPreferenceC override fun onStartListening() { super.onStartListening() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - prefs.registerOnSharedPreferenceChangeListener(this) + prefs.prefs.registerOnSharedPreferenceChangeListener(this) refreshTileState() } override fun onStopListening() { super.onStopListening() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - prefs.unregisterOnSharedPreferenceChangeListener(this) + prefs.prefs.unregisterOnSharedPreferenceChangeListener(this) } @SuppressLint("StartActivityAndCollapseDeprecated") diff --git a/app/src/main/java/com/chiller3/bcr/extension/DocumentFileExtensions.kt b/app/src/main/java/com/chiller3/bcr/extension/DocumentFileExtensions.kt index 3e3bb068f..41623eb10 100644 --- a/app/src/main/java/com/chiller3/bcr/extension/DocumentFileExtensions.kt +++ b/app/src/main/java/com/chiller3/bcr/extension/DocumentFileExtensions.kt @@ -148,11 +148,18 @@ fun DocumentFile.findNestedFile(path: List): DocumentFile? { */ fun DocumentFile.findOrCreateDirectories(path: List): DocumentFile? { var file = this + + // The root may not necessarily exist if it's a regular filesystem path. + if (uri.scheme == ContentResolver.SCHEME_FILE) { + uri.toFile().mkdirs() + } + for (segment in path) { file = file.findFileFast(segment) ?: file.createDirectory(segment) ?: return null } + return file } diff --git a/app/src/main/java/com/chiller3/bcr/extension/UriExtensions.kt b/app/src/main/java/com/chiller3/bcr/extension/UriExtensions.kt index 6fa169c29..6c0be085e 100644 --- a/app/src/main/java/com/chiller3/bcr/extension/UriExtensions.kt +++ b/app/src/main/java/com/chiller3/bcr/extension/UriExtensions.kt @@ -1,9 +1,12 @@ package com.chiller3.bcr.extension import android.content.ContentResolver +import android.content.Context import android.net.Uri import android.provider.DocumentsContract import android.telecom.PhoneAccount +import androidx.core.net.toFile +import androidx.documentfile.provider.DocumentFile const val DOCUMENTSUI_AUTHORITY = "com.android.externalstorage.documents" @@ -44,3 +47,23 @@ fun Uri.safTreeToDocument(): Uri { val documentId = DocumentsContract.getTreeDocumentId(this) return DocumentsContract.buildDocumentUri(authority, documentId) } + +fun Uri.toDocumentFile(context: Context): DocumentFile = + when (scheme) { + ContentResolver.SCHEME_FILE -> DocumentFile.fromFile(toFile()) + ContentResolver.SCHEME_CONTENT -> { + val segments = pathSegments + + // These only return null on API <21. + if (segments.size == 4 && segments[0] == "tree" && segments[2] == "document") { + DocumentFile.fromSingleUri(context, this)!! + } else if (segments.size == 2 && segments[0] == "document") { + DocumentFile.fromSingleUri(context, this)!! + } else if (segments.size == 2 && segments[0] == "tree") { + DocumentFile.fromTreeUri(context, this)!! + } else { + throw IllegalStateException("Unsupported content URI: $this") + } + } + else -> throw IllegalArgumentException("Unsupported URI: $this") + } diff --git a/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt b/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt index 4125ddc58..e367191a2 100644 --- a/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt +++ b/app/src/main/java/com/chiller3/bcr/output/OutputDirUtils.kt @@ -7,6 +7,7 @@ import android.system.Int64Ref import android.system.Os import android.system.OsConstants import android.util.Log +import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile import com.chiller3.bcr.Preferences import com.chiller3.bcr.extension.NotEfficientlyMovableException @@ -16,6 +17,7 @@ import com.chiller3.bcr.extension.findNestedFile import com.chiller3.bcr.extension.findOrCreateDirectories import com.chiller3.bcr.extension.moveToDirectory import com.chiller3.bcr.extension.renameToPreserveExt +import com.chiller3.bcr.extension.toDocumentFile import java.io.FileNotFoundException import java.io.IOException @@ -136,10 +138,26 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto try { val targetFile = sourceFile.moveToDirectory(targetParent) if (targetFile != null) { - val oldFilename = targetFile.name!!.substringBeforeLast('.') + val hasExt = + !MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType).isNullOrEmpty() + + // We behave like SAF where the target path does not contain the file extension, but + // querying the current filename does contain the file extension. Only chop off the + // extension for comparison if there's supposed to be an extension. + val (oldFilename, renameFunc) = if (hasExt) { + Pair( + targetFile.name!!.substringBeforeLast('.'), + DocumentFile::renameToPreserveExt, + ) + } else { + Pair( + targetFile.name!!, + DocumentFile::renameTo, + ) + } val newFilename = targetPath.last() - if (oldFilename != newFilename && !targetFile.renameToPreserveExt(newFilename)) { + if (oldFilename != newFilename && !renameFunc(targetFile, newFilename)) { // We intentionally don't report this error so that the user can be shown the // valid, but incorrectly named, target file instead of the now non-existent // source file. @@ -177,23 +195,6 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto return createFileWithFallback(defaultDir, path, mimeType) } - /** - * Create [path] in the output directory. - * - * @param path The last element is the filename, which should not contain a file extension - * @param mimeType Determines the file extension - * - * @throws IOException if the file could not be created in the output directory - */ - fun createFileInOutputDir(path: List, mimeType: String): DocumentFile { - val userDir = prefs.outputDir?.let { - // Only returns null on API <21 - DocumentFile.fromTreeUri(context, it)!! - } ?: DocumentFile.fromFile(prefs.defaultOutputDir) - - return createFileWithFallback(userDir, path, mimeType) - } - /** * Try to move [sourceFile] to the user output directory at [path]. * @@ -204,11 +205,7 @@ class OutputDirUtils(private val context: Context, private val redactor: Redacto path: List, mimeType: String, ): DocumentFile? { - val userDir = prefs.outputDir?.let { - // Only returns null on API <21 - DocumentFile.fromTreeUri(context, it)!! - } ?: DocumentFile.fromFile(prefs.defaultOutputDir) - + val userDir = prefs.outputDirOrDefault.toDocumentFile(context) val redactedSource = redactor.redact(sourceFile.uri) return try { diff --git a/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt b/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt index 5066d0148..3df4b864d 100644 --- a/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt +++ b/app/src/main/java/com/chiller3/bcr/output/OutputFilenameGenerator.kt @@ -311,7 +311,7 @@ class OutputFilenameGenerator( .appendOffset("+HHMMss", "+0000") .toFormatter() - private fun splitPath(pathString: String) = pathString + fun splitPath(pathString: String) = pathString .splitToSequence('/') .filter { it.isNotEmpty() && it != "." && it != ".." } .toList() diff --git a/app/src/main/java/com/chiller3/bcr/settings/SettingsFragment.kt b/app/src/main/java/com/chiller3/bcr/settings/SettingsFragment.kt index fe2416c53..df14564ea 100644 --- a/app/src/main/java/com/chiller3/bcr/settings/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/settings/SettingsFragment.kt @@ -7,9 +7,11 @@ import android.net.Uri import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.preference.Preference +import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat import com.chiller3.bcr.BuildConfig +import com.chiller3.bcr.DirectBootMigrationService import com.chiller3.bcr.Permissions import com.chiller3.bcr.Preferences import com.chiller3.bcr.R @@ -27,12 +29,14 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan Preference.OnPreferenceClickListener, OnPreferenceLongClickListener, SharedPreferences.OnSharedPreferenceChangeListener { private lateinit var prefs: Preferences + private lateinit var categoryDebug: PreferenceCategory private lateinit var prefCallRecording: SwitchPreferenceCompat private lateinit var prefRecordRules: Preference private lateinit var prefOutputDir: LongClickablePreference private lateinit var prefOutputFormat: Preference private lateinit var prefInhibitBatteryOpt: SwitchPreferenceCompat private lateinit var prefVersion: LongClickablePreference + private lateinit var prefMigrateDirectBoot: Preference private val requestPermissionRequired = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted -> @@ -49,12 +53,15 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.root_preferences, rootKey) - val context = requireContext() + preferenceManager.setStorageDeviceProtected() + setPreferencesFromResource(R.xml.root_preferences, rootKey) + prefs = Preferences(context) + categoryDebug = findPreference(Preferences.CATEGORY_DEBUG)!! + // If the desired state is enabled, set to disabled if runtime permissions have been // denied. The user will have to grant permissions again to re-enable the features. @@ -83,6 +90,11 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan prefVersion.onPreferenceClickListener = this prefVersion.onPreferenceLongClickListener = this refreshVersion() + + prefMigrateDirectBoot = findPreference(Preferences.PREF_MIGRATE_DIRECT_BOOT)!! + prefMigrateDirectBoot.onPreferenceClickListener = this + + refreshDebugPrefs() } override fun onStart() { @@ -133,6 +145,10 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan prefVersion.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}${suffix})" } + private fun refreshDebugPrefs() { + categoryDebug.isVisible = prefs.isDebugMode + } + private fun refreshInhibitBatteryOptState() { val inhibiting = Permissions.isInhibitingBatteryOpt(requireContext()) prefInhibitBatteryOpt.isChecked = inhibiting @@ -184,6 +200,13 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan startActivity(Intent(Intent.ACTION_VIEW, uri)) return true } + prefMigrateDirectBoot -> { + val context = requireContext() + context.startForegroundService( + Intent(context, DirectBootMigrationService::class.java) + ) + return true + } } return false @@ -206,6 +229,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceChan prefVersion -> { prefs.isDebugMode = !prefs.isDebugMode refreshVersion() + refreshDebugPrefs() return true } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5453503c..5eda6fcde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ General About + Debug Call recording @@ -33,6 +34,13 @@ Version + + Force direct boot mode + Pretend that the device is in the before first unlock state for testing. + + Migrate direct boot recordings + Migrate recordings made before the first unlock. This normally happens automatically after the first unlock. + Rules Add rule @@ -112,6 +120,9 @@ Alerts for errors during call recording Success alerts Alerts for successful call recordings + Migrating recordings + Failed to migrate recordings + Some recordings saved before the device was initially unlocked could not be moved to the output directory. Call recording initializing Call recording in progress Call recording finalizing diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index 0698565ca..96ffbcfb1 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -66,4 +66,23 @@ app:title="@string/pref_version_name" app:iconSpaceReserved="false" /> + + + + + + +