Repository: bitwarden/android
Pull request #5029: PM-20133: Add initial logging logic
Author: login: david-livefront
Created: 2025-04-10T22:34:04Z | Merged: 2025-04-11T17:57:42Z

## 🎟️ Tracking

[PM-20133](https://bitwarden.atlassian.net/browse/PM-20133)

## 📔 Objective

This PR implements the underlying logic for creating and writing to a file on disk for the flight recorder feature.

## ⏰ Reminders before review

- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

## 🦮 Reviewer guidelines

<!-- Suggested interactions but feel free to use (or not) as you desire! -->

- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes


[PM-20133]: https://bitwarden.atlassian.net/browse/PM-20133?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt
index e9d29b210aa..bf12899cb0a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt
@@ -55,6 +55,10 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
 import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
 import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
 import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManagerImpl
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriter
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderWriterImpl
 import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
 import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
 import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
@@ -70,6 +74,7 @@ import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
 import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
 import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
 import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
+import com.x8bit.bitwarden.data.vault.manager.FileManager
 import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
 import com.x8bit.bitwarden.data.vault.repository.VaultRepository
 import dagger.Module
@@ -94,6 +99,34 @@ object PlatformManagerModule {
         application: Application,
     ): AppStateManager = AppStateManagerImpl(application = application)
 
+    @Provides
+    @Singleton
+    fun provideFlightRecorderWriter(
+        clock: Clock,
+        fileManager: FileManager,
+        dispatcherManager: DispatcherManager,
+    ): FlightRecorderWriter = FlightRecorderWriterImpl(
+        clock = clock,
+        fileManager = fileManager,
+        dispatcherManager = dispatcherManager,
+    )
+
+    @Provides
+    @Singleton
+    fun provideFlightRecorderManager(
+        @ApplicationContext context: Context,
+        clock: Clock,
+        dispatcherManager: DispatcherManager,
+        settingsDiskSource: SettingsDiskSource,
+        flightRecorderWriter: FlightRecorderWriter,
+    ): FlightRecorderManager = FlightRecorderManagerImpl(
+        context = context,
+        clock = clock,
+        dispatcherManager = dispatcherManager,
+        settingsDiskSource = settingsDiskSource,
+        flightRecorderWriter = flightRecorderWriter,
+    )
+
     @Provides
     @Singleton
     fun provideAuthenticatorBridgeProcessor(
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt
new file mode 100644
index 00000000000..17222218098
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManager.kt
@@ -0,0 +1,40 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Manager class that handles recording logs for the flight recorder.
+ */
+interface FlightRecorderManager {
+    /**
+     * Returns a set of all flight recorder data currently stored on the device.
+     */
+    val flightRecorderData: FlightRecorderDataSet
+
+    /**
+     * Tracks changes to [FlightRecorderDataSet].
+     */
+    val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
+
+    /**
+     * Starts the flight recorder for the given [duration].
+     */
+    fun startFlightRecorder(duration: FlightRecorderDuration)
+
+    /**
+     * Cancels the active flight recorder if one is currently active.
+     */
+    fun endFlightRecorder()
+
+    /**
+     * Deletes the raw log file and metadata associated with the [data].
+     */
+    fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData)
+
+    /**
+     * Deletes the raw log files and metadata.
+     */
+    fun deleteAllLogs()
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt
new file mode 100644
index 00000000000..150d5a33fc8
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt
@@ -0,0 +1,193 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.time.Clock
+import java.util.UUID
+
+/**
+ * The default implementation of the [FlightRecorderManager].
+ */
+internal class FlightRecorderManagerImpl(
+    private val context: Context,
+    private val clock: Clock,
+    private val settingsDiskSource: SettingsDiskSource,
+    private val flightRecorderWriter: FlightRecorderWriter,
+    dispatcherManager: DispatcherManager,
+) : FlightRecorderManager {
+    private val unconfinedScope = CoroutineScope(context = dispatcherManager.unconfined)
+    private val ioScope = CoroutineScope(context = dispatcherManager.io)
+    private var cancellationJob: Job = Job().apply { complete() }
+    private val flightRecorderTree = FlightRecorderTree()
+
+    override val flightRecorderData: FlightRecorderDataSet
+        get() = settingsDiskSource.flightRecorderData ?: FlightRecorderDataSet(data = emptySet())
+
+    override val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
+        get() = settingsDiskSource
+            .flightRecorderDataFlow
+            .map { it ?: FlightRecorderDataSet(data = emptySet()) }
+            .stateIn(
+                scope = unconfinedScope,
+                started = SharingStarted.Eagerly,
+                initialValue = flightRecorderData,
+            )
+
+    init {
+        // Always plant the tree, it will not log anything if the flight recorder is off.
+        Timber.plant(flightRecorderTree)
+        flightRecorderDataFlow
+            .onEach { it.configureFlightRecorder() }
+            .launchIn(scope = unconfinedScope)
+        context.registerReceiver(
+            ScreenStateBroadcastReceiver(),
+            IntentFilter(Intent.ACTION_SCREEN_ON),
+        )
+    }
+
+    override fun startFlightRecorder(duration: FlightRecorderDuration) {
+        val startTime = clock.instant()
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData
+                .data
+                .map { it.copy(isActive = false) }
+                .toMutableSet()
+                .apply {
+                    val formattedTime = startTime.toFormattedPattern(
+                        pattern = "yyyy-MM-dd_HH-mm-ss",
+                        clock = clock,
+                    )
+                    add(
+                        element = FlightRecorderDataSet.FlightRecorderData(
+                            id = UUID.randomUUID().toString(),
+                            fileName = "flight_recorder_$formattedTime",
+                            startTimeMs = startTime.toEpochMilli(),
+                            durationMs = duration.milliseconds,
+                            isActive = true,
+                        ),
+                    )
+                },
+        )
+    }
+
+    override fun endFlightRecorder() {
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.map { it.copy(isActive = false) }.toSet(),
+        )
+    }
+
+    override fun deleteAllLogs() {
+        val activeLog = flightRecorderData.activeFlightRecorderData
+        val inactiveDataset = flightRecorderData.copy(
+            data = flightRecorderData.data.filterNot { it.isActive }.toSet(),
+        )
+        // Clear everything but the active log.
+        settingsDiskSource.flightRecorderData = activeLog?.let {
+            FlightRecorderDataSet(data = setOf(it))
+        }
+        // Clear all logs but the active one.
+        ioScope.launch { flightRecorderWriter.deleteLogs(dataset = inactiveDataset) }
+    }
+
+    override fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        if (data.isActive) return
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.filterNot { it == data }.toSet(),
+        )
+        ioScope.launch { flightRecorderWriter.deleteLog(data = data) }
+    }
+
+    private fun cancelCancellationJob() {
+        cancellationJob.cancel()
+    }
+
+    private fun startCancellationJob(data: FlightRecorderDataSet.FlightRecorderData) {
+        cancelCancellationJob()
+        cancellationJob = unconfinedScope.launch {
+            val endTimeMs = data.startTimeMs + data.durationMs
+            val remainingTimeMs = endTimeMs - clock.instant().toEpochMilli()
+            delay(timeMillis = remainingTimeMs)
+            endFlightRecorder()
+        }
+    }
+
+    /**
+     * Configures the the flight recorder based on the [FlightRecorderDataSet].
+     *
+     * This sets the log to ensure that the log file is being updated properly as well as starting
+     * the cancellation job.
+     */
+    private fun FlightRecorderDataSet.configureFlightRecorder() {
+        this
+            .activeFlightRecorderData
+            ?.let {
+                // Set the file data to be recorded too.
+                flightRecorderTree.flightRecorderData = it
+                startCancellationJob(data = it)
+            }
+            ?: run {
+                // Clear the file data to stop recording.
+                flightRecorderTree.flightRecorderData = null
+                cancelCancellationJob()
+            }
+    }
+
+    private inner class FlightRecorderTree : Timber.Tree() {
+        var flightRecorderData: FlightRecorderDataSet.FlightRecorderData? = null
+            set(value) {
+                value?.let {
+                    ioScope.launch { flightRecorderWriter.getOrCreateLogFile(data = it) }
+                }
+                field = value
+            }
+
+        override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+            flightRecorderData?.let {
+                ioScope.launch {
+                    flightRecorderWriter.writeToLog(
+                        data = it,
+                        priority = priority,
+                        tag = tag,
+                        message = message,
+                        throwable = t,
+                    )
+                }
+            }
+        }
+    }
+
+    /**
+     * A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
+     * cancellation job to ensure they complete at the correct time.
+     *
+     * This is necessary because the [delay] function in a coroutine will not keep accurate time
+     * when the screen is off. We do not cancel the job when the screen is off, this allows the
+     * job to complete as-soon-as possible if the screen is powered off for an extended period.
+     */
+    private inner class ScreenStateBroadcastReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            flightRecorderData.configureFlightRecorder()
+        }
+    }
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriter.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriter.kt
new file mode 100644
index 00000000000..5132386382f
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriter.kt
@@ -0,0 +1,36 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import java.io.File
+
+/**
+ * Helper for creating and writing log files.
+ */
+interface FlightRecorderWriter {
+    /**
+     * Deletes the file associated with the [data].
+     */
+    suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData)
+
+    /**
+     * Deletes all files associated with the [dataset].
+     */
+    suspend fun deleteLogs(dataset: FlightRecorderDataSet)
+
+    /**
+     * Creates or retrieves already created log files. If a new file is created, it will be
+     * pre-populated with metadata.
+     */
+    suspend fun getOrCreateLogFile(data: FlightRecorderDataSet.FlightRecorderData): Result<File>
+
+    /**
+     * Formats the data and writes it to the log file.
+     */
+    suspend fun writeToLog(
+        data: FlightRecorderDataSet.FlightRecorderData,
+        priority: Int,
+        tag: String?,
+        message: String,
+        throwable: Throwable?,
+    )
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt
new file mode 100644
index 00000000000..f1011a7e91b
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt
@@ -0,0 +1,141 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.os.Build
+import android.util.Log
+import com.bitwarden.core.annotation.OmitFromCoverage
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.BuildConfig
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.vault.manager.FileManager
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.time.Clock
+import java.time.Instant
+import kotlin.time.Duration.Companion.milliseconds
+
+private const val LOG_TIME_PATTERN: String = "yyyy-MM-dd HH:mm:ss:SSS"
+
+/**
+ * The default implementation of the [FlightRecorderWriter].
+ */
+@OmitFromCoverage
+class FlightRecorderWriterImpl(
+    private val clock: Clock,
+    private val fileManager: FileManager,
+    private val dispatcherManager: DispatcherManager,
+) : FlightRecorderWriter {
+    override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
+    }
+
+    override suspend fun deleteLogs(dataset: FlightRecorderDataSet) {
+        fileManager.delete(
+            files = dataset
+                .data
+                .map { File(File(fileManager.logsDirectory), it.fileName) }
+                .toTypedArray(),
+        )
+    }
+
+    override suspend fun getOrCreateLogFile(
+        data: FlightRecorderDataSet.FlightRecorderData,
+    ): Result<File> = withContext(dispatcherManager.io) {
+        runCatching {
+            val logFolder = File(fileManager.logsDirectory)
+            if (!logFolder.exists()) logFolder.mkdirs()
+            val logFile = File(logFolder, data.fileName)
+            if (!logFile.exists()) {
+                logFile.createNewFile()
+                val startTime = Instant
+                    .ofEpochMilli(data.startTimeMs)
+                    .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+                val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
+                val operatingSystem = "${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})"
+                // Upon creating the new file, we pre-populate it with basic data
+                BufferedWriter(FileWriter(logFile, true)).use { bw ->
+                    bw.append("Bitwarden Android")
+                    bw.newLine()
+                    bw.append("Log Start Time: $startTime")
+                    bw.newLine()
+                    bw.append("Log Duration: ${data.durationMs.milliseconds}")
+                    bw.newLine()
+                    bw.append("App Version: $appVersion")
+                    bw.newLine()
+                    bw.append("Build: ${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}")
+                    bw.newLine()
+                    bw.append("Operating System: $operatingSystem")
+                    bw.newLine()
+                    bw.append("Device: ${Build.BRAND} ${Build.MODEL}")
+                    bw.newLine()
+                }
+            }
+            logFile
+        }
+    }
+
+    override suspend fun writeToLog(
+        data: FlightRecorderDataSet.FlightRecorderData,
+        priority: Int,
+        tag: String?,
+        message: String,
+        throwable: Throwable?,
+    ) {
+        val logFile = getOrCreateLogFile(data = data).getOrNull() ?: return
+        val formattedTime = clock
+            .instant()
+            .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+        withContext(context = dispatcherManager.io) {
+            runCatching {
+                BufferedWriter(FileWriter(logFile, true)).use { bw ->
+                    bw.append(formattedTime)
+                    bw.append(" – ")
+                    bw.append(priority.logLevel)
+                    tag?.let {
+                        bw.append(" – ")
+                        bw.append(it)
+                    }
+                    bw.append(" – ")
+                    bw.append(message)
+                    throwable?.let {
+                        bw.append(" – ")
+                        bw.append(it.getStackTraceString())
+                    }
+                    bw.newLine()
+                }
+            }
+        }
+    }
+}
+
+/**
+ * Helper function modifier from the [Timber] library.
+ */
+@Suppress("MagicNumber")
+private fun Throwable.getStackTraceString(): String {
+    // Don't replace this with Log.getStackTraceString() - it hides
+    // UnknownHostException, which is not what we want.
+    return StringWriter(256).use { sw ->
+        PrintWriter(sw, false).use { pw ->
+            this.printStackTrace(pw)
+            pw.flush()
+        }
+        sw.toString()
+    }
+}
+
+private val Int.logLevel: String
+    get() = when (this) {
+        Log.VERBOSE -> "VERBOSE"
+        Log.DEBUG -> "DEBUG"
+        Log.INFO -> "INFO"
+        Log.WARN -> "WARNING"
+        Log.ERROR -> "ERROR"
+        Log.ASSERT -> "ASSERT"
+        else -> "UNKNOWN"
+    }
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
index 0cd546b8578..6e4d329805a 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt
@@ -1,6 +1,7 @@
 package com.x8bit.bitwarden.data.platform.repository
 
 import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
 import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
 import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
 import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@@ -17,7 +18,7 @@ import javax.crypto.Cipher
  * Provides an API for observing and modifying settings state.
  */
 @Suppress("TooManyFunctions")
-interface SettingsRepository {
+interface SettingsRepository : FlightRecorderManager {
     /**
      * The [AppLanguage] for the current user.
      */
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
index fda58f09459..1861a509095 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt
@@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
 import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
 import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
 import com.x8bit.bitwarden.data.platform.manager.PolicyManager
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
 import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
 import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
 import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@@ -49,10 +50,12 @@ class SettingsRepositoryImpl(
     private val authDiskSource: AuthDiskSource,
     private val settingsDiskSource: SettingsDiskSource,
     private val vaultSdkSource: VaultSdkSource,
+    flightRecorderManager: FlightRecorderManager,
     accessibilityEnabledManager: AccessibilityEnabledManager,
     policyManager: PolicyManager,
     dispatcherManager: DispatcherManager,
-) : SettingsRepository {
+) : SettingsRepository,
+    FlightRecorderManager by flightRecorderManager {
     private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
 
     private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt
index dec7a2c2880..9eef7289239 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
 import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
 import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
 import com.x8bit.bitwarden.data.platform.manager.PolicyManager
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
 import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepository
 import com.x8bit.bitwarden.data.platform.repository.AuthenticatorBridgeRepositoryImpl
 import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
@@ -75,6 +76,7 @@ object PlatformRepositoryModule {
         accessibilityEnabledManager: AccessibilityEnabledManager,
         dispatcherManager: DispatcherManager,
         policyManager: PolicyManager,
+        flightRecorderManager: FlightRecorderManager,
     ): SettingsRepository =
         SettingsRepositoryImpl(
             autofillManager = autofillManager,
@@ -85,6 +87,7 @@ object PlatformRepositoryModule {
             accessibilityEnabledManager = accessibilityEnabledManager,
             dispatcherManager = dispatcherManager,
             policyManager = policyManager,
+            flightRecorderManager = flightRecorderManager,
         )
 
     @Provides
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt
index c1ec2645f1c..409e44dc1c0 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt
@@ -16,6 +16,11 @@ interface FileManager {
      */
     val filesDirectory: String
 
+    /**
+     * Absolute path to the private logs storage directory.
+     */
+    val logsDirectory: String
+
     /**
      * Deletes [files] from disk.
      */
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt
index b1696bed13d..55fa7470b1d 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt
@@ -32,6 +32,9 @@ class FileManagerImpl(
     override val filesDirectory: String
         get() = context.filesDir.absolutePath
 
+    override val logsDirectory: String
+        get() = "${context.dataDir}/logs"
+
     override suspend fun delete(vararg files: File) {
         withContext(dispatcherManager.io) {
             files.forEach { it.delete() }
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
index 8eb1dfd8627..9189232881b 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.onSubscription
 import kotlinx.serialization.json.Json
+import org.junit.jupiter.api.Assertions.assertEquals
 import java.time.Instant
 
 /**
@@ -446,6 +447,13 @@ class FakeSettingsDiskSource : SettingsDiskSource {
         return storedAppResumeScreenData[userId]?.let { Json.decodeFromStringOrNull(it) }
     }
 
+    /**
+     * Asserts that the stored [FlightRecorderDataSet] matches the [expected] one.
+     */
+    fun assertFlightRecorderData(expected: FlightRecorderDataSet) {
+        assertEquals(expected, storedFlightRecorderData)
+    }
+
     //region Private helper functions
     private fun getMutableLastSyncTimeFlow(
         userId: String,
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt
new file mode 100644
index 00000000000..7bd5a7adf5c
--- /dev/null
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerTest.kt
@@ -0,0 +1,259 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import app.cash.turbine.test
+import com.bitwarden.core.data.util.asSuccess
+import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
+import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.io.File
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.UUID
+
+class FlightRecorderManagerTest {
+    private val broadcastReceiver = slot<BroadcastReceiver>()
+
+    @SuppressLint("UnspecifiedRegisterReceiverFlag")
+    private val context: Context = mockk {
+        every { registerReceiver(capture(broadcastReceiver), any()) } returns null
+    }
+    private val fakeSettingsDiskSource = FakeSettingsDiskSource()
+    private val flightRecorderWriter = mockk<FlightRecorderWriter> {
+        coEvery { getOrCreateLogFile(data = any()) } returns mockk<File>().asSuccess()
+        coEvery { deleteLogs(dataset = any()) } just runs
+        coEvery { deleteLog(data = any()) } just runs
+        coEvery {
+            writeToLog(
+                data = any(),
+                priority = any(),
+                tag = any(),
+                message = any(),
+                throwable = any(),
+            )
+        } just runs
+    }
+    private val fakeDispatcherManager = FakeDispatcherManager()
+
+    private val flightRecorder = FlightRecorderManagerImpl(
+        context = context,
+        clock = FIXED_CLOCK,
+        settingsDiskSource = fakeSettingsDiskSource,
+        flightRecorderWriter = flightRecorderWriter,
+        dispatcherManager = fakeDispatcherManager,
+    )
+
+    @BeforeEach
+    fun setup() {
+        mockkStatic(UUID::class)
+        every { UUID.randomUUID().toString() } returns "mockUUID"
+    }
+
+    @AfterEach
+    fun tearDown() {
+        unmockkStatic(UUID::class)
+    }
+
+    @Test
+    fun `flightRecorderData should pull from and update SettingsDiskSource`() {
+        fakeSettingsDiskSource.flightRecorderData = null
+        assertEquals(FlightRecorderDataSet(data = emptySet()), flightRecorder.flightRecorderData)
+
+        val expected = mockk<FlightRecorderDataSet> {
+            every { activeFlightRecorderData } returns null
+        }
+        fakeSettingsDiskSource.flightRecorderData = expected
+        assertEquals(expected, flightRecorder.flightRecorderData)
+    }
+
+    @Test
+    fun `flightRecorderDataFlow should react to changes in SettingsDiskSource`() = runTest {
+        fakeSettingsDiskSource.flightRecorderData = null
+        flightRecorder
+            .flightRecorderDataFlow
+            .test {
+                assertEquals(FlightRecorderDataSet(data = emptySet()), awaitItem())
+
+                val expected = FlightRecorderDataSet(
+                    data = setOf(
+                        FlightRecorderDataSet.FlightRecorderData(
+                            id = "mockUUID",
+                            fileName = "flight_recorder_2023-10-27_12-00-00",
+                            startTimeMs = FIXED_CLOCK_TIME,
+                            durationMs = FlightRecorderDuration.ONE_HOUR.milliseconds,
+                            isActive = false,
+                        ),
+                    ),
+                )
+                fakeSettingsDiskSource.flightRecorderData = expected
+                assertEquals(expected, awaitItem())
+
+                fakeSettingsDiskSource.flightRecorderData = null
+                assertEquals(FlightRecorderDataSet(data = emptySet()), awaitItem())
+            }
+    }
+
+    @Test
+    fun `startFlightRecorder should properly update SettingsDiskSource`() {
+        fakeSettingsDiskSource.flightRecorderData = null
+
+        flightRecorder.startFlightRecorder(duration = FlightRecorderDuration.ONE_HOUR)
+
+        fakeSettingsDiskSource.assertFlightRecorderData(
+            expected = FlightRecorderDataSet(
+                data = setOf(
+                    FlightRecorderDataSet.FlightRecorderData(
+                        id = "mockUUID",
+                        fileName = "flight_recorder_2023-10-27_12-00-00",
+                        startTimeMs = FIXED_CLOCK_TIME,
+                        durationMs = FlightRecorderDuration.ONE_HOUR.milliseconds,
+                        isActive = true,
+                    ),
+                ),
+            ),
+        )
+    }
+
+    @Suppress("MaxLineLength")
+    @Test
+    fun `endFlightRecorder should set the active log to inactive and update the SettingsDiskSource`() {
+        val data = FlightRecorderDataSet(
+            data = setOf(
+                FlightRecorderDataSet.FlightRecorderData(
+                    id = "40",
+                    fileName = "fileName1",
+                    startTimeMs = FIXED_CLOCK_TIME,
+                    durationMs = 60L,
+                    isActive = true,
+                ),
+                FlightRecorderDataSet.FlightRecorderData(
+                    id = "50",
+                    fileName = "fileName2",
+                    startTimeMs = FIXED_CLOCK_TIME,
+                    durationMs = 60L,
+                    isActive = false,
+                ),
+            ),
+        )
+        fakeSettingsDiskSource.flightRecorderData = data
+
+        flightRecorder.endFlightRecorder()
+
+        fakeSettingsDiskSource.assertFlightRecorderData(
+            expected = FlightRecorderDataSet(
+                data = setOf(
+                    FlightRecorderDataSet.FlightRecorderData(
+                        id = "40",
+                        fileName = "fileName1",
+                        startTimeMs = FIXED_CLOCK_TIME,
+                        durationMs = 60L,
+                        isActive = false,
+                    ),
+                    FlightRecorderDataSet.FlightRecorderData(
+                        id = "50",
+                        fileName = "fileName2",
+                        startTimeMs = FIXED_CLOCK_TIME,
+                        durationMs = 60L,
+                        isActive = false,
+                    ),
+                ),
+            ),
+        )
+    }
+
+    @Test
+    fun `deleteAllLogs should clear the metadata for all non-active logs and call deleteLogs`() {
+        val inactiveData = FlightRecorderDataSet.FlightRecorderData(
+            id = "50",
+            fileName = "fileName1",
+            startTimeMs = FIXED_CLOCK_TIME,
+            durationMs = 60L,
+            isActive = false,
+        )
+        val activeData = FlightRecorderDataSet.FlightRecorderData(
+            id = "50",
+            fileName = "fileName2",
+            startTimeMs = FIXED_CLOCK_TIME,
+            durationMs = 60L,
+            isActive = true,
+        )
+        val activeDataset = FlightRecorderDataSet(data = setOf(activeData))
+        val inactiveDataset = FlightRecorderDataSet(data = setOf(inactiveData))
+
+        fakeSettingsDiskSource.flightRecorderData = FlightRecorderDataSet(
+            data = setOf(activeData, inactiveData),
+        )
+
+        flightRecorder.deleteAllLogs()
+
+        coVerify(exactly = 1) {
+            flightRecorderWriter.deleteLogs(dataset = inactiveDataset)
+        }
+        fakeSettingsDiskSource.assertFlightRecorderData(expected = activeDataset)
+    }
+
+    @Test
+    fun `deleteLog with active log should do nothing`() {
+        val data = FlightRecorderDataSet.FlightRecorderData(
+            id = "50",
+            fileName = "fileName1",
+            startTimeMs = FIXED_CLOCK_TIME,
+            durationMs = 60L,
+            isActive = true,
+        )
+        val dataset = FlightRecorderDataSet(data = setOf(data))
+        fakeSettingsDiskSource.flightRecorderData = dataset
+
+        flightRecorder.deleteLog(data = data)
+
+        coVerify(exactly = 0) {
+            flightRecorderWriter.deleteLog(data = data)
+        }
+        fakeSettingsDiskSource.assertFlightRecorderData(expected = dataset)
+    }
+
+    @Test
+    fun `deleteLog with inactive log should clear the metadata for the log and call deleteLog`() {
+        val data = FlightRecorderDataSet.FlightRecorderData(
+            id = "50",
+            fileName = "fileName1",
+            startTimeMs = FIXED_CLOCK_TIME,
+            durationMs = 60L,
+            isActive = false,
+        )
+        fakeSettingsDiskSource.flightRecorderData = FlightRecorderDataSet(data = setOf(data))
+
+        flightRecorder.deleteLog(data = data)
+
+        coVerify(exactly = 1) {
+            flightRecorderWriter.deleteLog(data = data)
+        }
+        fakeSettingsDiskSource.assertFlightRecorderData(
+            expected = FlightRecorderDataSet(data = emptySet()),
+        )
+    }
+}
+
+private const val FIXED_CLOCK_TIME: Long = 1_698_408_000_000L
+private val FIXED_CLOCK: Clock = Clock.fixed(
+    Instant.parse("2023-10-27T12:00:00Z"),
+    ZoneOffset.UTC,
+)
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
index 702f4220d8a..338ff9d16b0 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt
@@ -24,6 +24,7 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManagerImpl
 import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
 import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
 import com.x8bit.bitwarden.data.platform.manager.PolicyManager
+import com.x8bit.bitwarden.data.platform.manager.flightrecorder.FlightRecorderManager
 import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
 import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
 import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
@@ -70,6 +71,7 @@ class SettingsRepositoryTest {
             getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
         } returns mutableActivePolicyFlow
     }
+    private val flightRecorderManager = mockk<FlightRecorderManager>()
 
     private val settingsRepository = SettingsRepositoryImpl(
         autofillManager = autofillManager,
@@ -80,6 +82,7 @@ class SettingsRepositoryTest {
         accessibilityEnabledManager = fakeAccessibilityEnabledManager,
         dispatcherManager = FakeDispatcherManager(),
         policyManager = policyManager,
+        flightRecorderManager = flightRecorderManager,
     )
 
     @BeforeEach


Reviews:
user: login: SaintPatrck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T13:56:30Z
user: login: david-livefront
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T14:46:28Z
user: login: SaintPatrck
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T15:41:57Z
user: login: david-livefront
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T15:43:40Z
user: login: SaintPatrck
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T17:33:04Z

Review comments:
diff_hunk: @@ -0,0 +1,193 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.time.Clock
+import java.util.UUID
+
+/**
+ * The default implementation of the [FlightRecorderManager].
+ */
+internal class FlightRecorderManagerImpl(
+    private val context: Context,
+    private val clock: Clock,
+    private val settingsDiskSource: SettingsDiskSource,
+    private val flightRecorderWriter: FlightRecorderWriter,
+    dispatcherManager: DispatcherManager,
+) : FlightRecorderManager {
+    private val unconfinedScope = CoroutineScope(context = dispatcherManager.unconfined)
+    private val ioScope = CoroutineScope(context = dispatcherManager.io)
+    private var cancellationJob: Job = Job().apply { complete() }
+    private val flightRecorderTree = FlightRecorderTree()
+
+    override val flightRecorderData: FlightRecorderDataSet
+        get() = settingsDiskSource.flightRecorderData ?: FlightRecorderDataSet(data = emptySet())
+
+    override val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
+        get() = settingsDiskSource
+            .flightRecorderDataFlow
+            .map { it ?: FlightRecorderDataSet(data = emptySet()) }
+            .stateIn(
+                scope = unconfinedScope,
+                started = SharingStarted.Eagerly,
+                initialValue = flightRecorderData,
+            )
+
+    override fun startFlightRecorder(duration: FlightRecorderDuration) {
+        val startTime = clock.instant()
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData
+                .data
+                .map { it.copy(isActive = false) }
+                .toMutableSet()
+                .apply {
+                    val formattedTime = startTime.toFormattedPattern(
+                        pattern = "yyyy-MM-dd_HH-mm-ss",
+                        clock = clock,
+                    )
+                    add(
+                        element = FlightRecorderDataSet.FlightRecorderData(
+                            id = UUID.randomUUID().toString(),
+                            fileName = "flight_recorder_$formattedTime",
+                            startTimeMs = startTime.toEpochMilli(),
+                            durationMs = duration.milliseconds,
+                            isActive = true,
+                        ),
+                    )
+                },
+        )
+    }
+
+    override fun endFlightRecorder() {
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.map { it.copy(isActive = false) }.toSet(),
+        )
+    }
+
+    override fun deleteAllLogs() {
+        val activeLog = flightRecorderData.activeFlightRecorderData
+        val inactiveDataset = flightRecorderData.copy(
+            data = flightRecorderData.data.filterNot { it.isActive }.toSet(),
+        )
+        // Clear everything but the active log.
+        settingsDiskSource.flightRecorderData = activeLog?.let {
+            FlightRecorderDataSet(data = setOf(it))
+        }
+        // Clear all logs but the active one.
+        ioScope.launch { flightRecorderWriter.deleteLogs(dataset = inactiveDataset) }
+    }
+
+    override fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        if (data.isActive) return
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.filterNot { it == data }.toSet(),
+        )
+        ioScope.launch { flightRecorderWriter.deleteLog(data = data) }
+    }
+
+    init {
path: app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt
user: login: SaintPatrck
body: ⛏️ Can this be moved to be before the first `fun` declarations?
created_at: 2025-04-11T13:37:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,193 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.platform.repository.model.FlightRecorderDuration
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.time.Clock
+import java.util.UUID
+
+/**
+ * The default implementation of the [FlightRecorderManager].
+ */
+internal class FlightRecorderManagerImpl(
+    private val context: Context,
+    private val clock: Clock,
+    private val settingsDiskSource: SettingsDiskSource,
+    private val flightRecorderWriter: FlightRecorderWriter,
+    dispatcherManager: DispatcherManager,
+) : FlightRecorderManager {
+    private val unconfinedScope = CoroutineScope(context = dispatcherManager.unconfined)
+    private val ioScope = CoroutineScope(context = dispatcherManager.io)
+    private var cancellationJob: Job = Job().apply { complete() }
+    private val flightRecorderTree = FlightRecorderTree()
+
+    override val flightRecorderData: FlightRecorderDataSet
+        get() = settingsDiskSource.flightRecorderData ?: FlightRecorderDataSet(data = emptySet())
+
+    override val flightRecorderDataFlow: StateFlow<FlightRecorderDataSet>
+        get() = settingsDiskSource
+            .flightRecorderDataFlow
+            .map { it ?: FlightRecorderDataSet(data = emptySet()) }
+            .stateIn(
+                scope = unconfinedScope,
+                started = SharingStarted.Eagerly,
+                initialValue = flightRecorderData,
+            )
+
+    override fun startFlightRecorder(duration: FlightRecorderDuration) {
+        val startTime = clock.instant()
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData
+                .data
+                .map { it.copy(isActive = false) }
+                .toMutableSet()
+                .apply {
+                    val formattedTime = startTime.toFormattedPattern(
+                        pattern = "yyyy-MM-dd_HH-mm-ss",
+                        clock = clock,
+                    )
+                    add(
+                        element = FlightRecorderDataSet.FlightRecorderData(
+                            id = UUID.randomUUID().toString(),
+                            fileName = "flight_recorder_$formattedTime",
+                            startTimeMs = startTime.toEpochMilli(),
+                            durationMs = duration.milliseconds,
+                            isActive = true,
+                        ),
+                    )
+                },
+        )
+    }
+
+    override fun endFlightRecorder() {
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.map { it.copy(isActive = false) }.toSet(),
+        )
+    }
+
+    override fun deleteAllLogs() {
+        val activeLog = flightRecorderData.activeFlightRecorderData
+        val inactiveDataset = flightRecorderData.copy(
+            data = flightRecorderData.data.filterNot { it.isActive }.toSet(),
+        )
+        // Clear everything but the active log.
+        settingsDiskSource.flightRecorderData = activeLog?.let {
+            FlightRecorderDataSet(data = setOf(it))
+        }
+        // Clear all logs but the active one.
+        ioScope.launch { flightRecorderWriter.deleteLogs(dataset = inactiveDataset) }
+    }
+
+    override fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        if (data.isActive) return
+        val originalData = flightRecorderData
+        settingsDiskSource.flightRecorderData = originalData.copy(
+            data = originalData.data.filterNot { it == data }.toSet(),
+        )
+        ioScope.launch { flightRecorderWriter.deleteLog(data = data) }
+    }
+
+    init {
+        // Always plant the tree, it will not log anything if the flight recorder is off.
+        Timber.plant(flightRecorderTree)
+        flightRecorderDataFlow
+            .onEach { it.configureFlightRecorder() }
+            .launchIn(scope = unconfinedScope)
+        context.registerReceiver(
+            ScreenStateBroadcastReceiver(),
+            IntentFilter(Intent.ACTION_SCREEN_ON),
+        )
+    }
+
+    private fun cancelCancellationJob() {
+        cancellationJob.cancel()
+    }
+
+    private fun startCancellationJob(data: FlightRecorderDataSet.FlightRecorderData) {
+        cancelCancellationJob()
+        cancellationJob = unconfinedScope.launch {
+            val endTimeMs = data.startTimeMs + data.durationMs
+            val remainingTimeMs = endTimeMs - clock.instant().toEpochMilli()
+            delay(timeMillis = remainingTimeMs)
+            endFlightRecorder()
+        }
+    }
+
+    /**
+     * Configures the the flight recorder based on the [FlightRecorderDataSet].
+     *
+     * This sets the log to ensure that the log file is being updated properly as well as starting
+     * the cancellation job.
+     */
+    private fun FlightRecorderDataSet.configureFlightRecorder() {
+        this
+            .activeFlightRecorderData
+            ?.let {
+                // Set the file data to be recorded too.
+                flightRecorderTree.flightRecorderData = it
+                startCancellationJob(data = it)
+            }
+            ?: run {
+                // Clear the file data to stop recording.
+                flightRecorderTree.flightRecorderData = null
+                cancelCancellationJob()
+            }
+    }
+
+    private inner class FlightRecorderTree : Timber.Tree() {
+        var flightRecorderData: FlightRecorderDataSet.FlightRecorderData? = null
+            set(value) {
+                value?.let {
+                    ioScope.launch { flightRecorderWriter.getOrCreateLogFile(data = it) }
+                }
+                field = value
+            }
+
+        override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+            flightRecorderData?.let {
+                ioScope.launch {
+                    flightRecorderWriter.writeToLog(
+                        data = it,
+                        priority = priority,
+                        tag = tag,
+                        message = message,
+                        throwable = t,
+                    )
+                }
+            }
+        }
+    }
+
+    /**
+     * A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
+     * cancellation job to ensure they complete at the correct time.
+     *
+     * This is necessary because the [delay] function in a coroutine will not keep accurate time
+     * when the screen is off. We do not cancel the job when the screen is off, this allows the
+     * job to complete as-soon-as possible if the screen is powered off for an extended period.
+     */
path: app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderManagerImpl.kt
user: login: SaintPatrck
body: 👍 
created_at: 2025-04-11T13:39:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,141 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.os.Build
+import android.util.Log
+import com.bitwarden.core.annotation.OmitFromCoverage
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.BuildConfig
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.vault.manager.FileManager
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.time.Clock
+import java.time.Instant
+import kotlin.time.Duration.Companion.milliseconds
+
+private const val LOG_TIME_PATTERN: String = "yyyy-MM-dd HH:mm:ss:SSS"
+
+/**
+ * The default implementation of the [FlightRecorderWriter].
+ */
+@OmitFromCoverage
+class FlightRecorderWriterImpl(
+    private val clock: Clock,
+    private val fileManager: FileManager,
+    private val dispatcherManager: DispatcherManager,
+) : FlightRecorderWriter {
+    override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
+    }
+
+    override suspend fun deleteLogs(dataset: FlightRecorderDataSet) {
+        fileManager.delete(
+            files = dataset
+                .data
+                .map { File(File(fileManager.logsDirectory), it.fileName) }
+                .toTypedArray(),
+        )
+    }
+
+    override suspend fun getOrCreateLogFile(
+        data: FlightRecorderDataSet.FlightRecorderData,
+    ): Result<File> = withContext(dispatcherManager.io) {
+        runCatching {
+            val logFolder = File(fileManager.logsDirectory)
+            if (!logFolder.exists()) logFolder.mkdirs()
+            val logFile = File(logFolder, data.fileName)
+            if (!logFile.exists()) {
+                logFile.createNewFile()
+                val startTime = Instant
+                    .ofEpochMilli(data.startTimeMs)
+                    .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+                // Upon creating the new file, we pre-populate it with basic data
+                BufferedWriter(FileWriter(logFile, true)).use { bw ->
+                    bw.append("Bitwarden Android")
+                    bw.newLine()
+                    bw.append("Log Start Time: $startTime")
+                    bw.newLine()
+                    bw.append("Log Duration: ${data.durationMs.milliseconds}")
+                    bw.newLine()
+                    bw.append(
+                        "App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
+                    )
+                    bw.newLine()
+                    bw.append("Build: ${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}")
+                    bw.newLine()
+                    bw.append(
+                        "Operating System: ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})"
+                    )
+                    bw.newLine()
+                    bw.append("Device: ${Build.BRAND} ${Build.MODEL}")
+                    bw.newLine()
+                }
+            }
+            logFile
+        }
+    }
+
+    override suspend fun writeToLog(
+        data: FlightRecorderDataSet.FlightRecorderData,
+        priority: Int,
+        tag: String?,
+        message: String,
+        throwable: Throwable?,
+    ) {
+        val logFile = getOrCreateLogFile(data = data).getOrNull() ?: return
+        val formattedTime = clock
+            .instant()
+            .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+        withContext(context = dispatcherManager.io) {
+            BufferedWriter(FileWriter(logFile, true)).use { bw ->
path: app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt
user: login: SaintPatrck
body: Should we be catching exceptions thrown here? I'm thinking about cases where disk is full. 

Along those same lines; should we do anything special when the disk is full, like stop the flight recorder? Or notify the user somehow?
created_at: 2025-04-11T13:52:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,141 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.os.Build
+import android.util.Log
+import com.bitwarden.core.annotation.OmitFromCoverage
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.BuildConfig
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.vault.manager.FileManager
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.time.Clock
+import java.time.Instant
+import kotlin.time.Duration.Companion.milliseconds
+
+private const val LOG_TIME_PATTERN: String = "yyyy-MM-dd HH:mm:ss:SSS"
+
+/**
+ * The default implementation of the [FlightRecorderWriter].
+ */
+@OmitFromCoverage
+class FlightRecorderWriterImpl(
+    private val clock: Clock,
+    private val fileManager: FileManager,
+    private val dispatcherManager: DispatcherManager,
+) : FlightRecorderWriter {
+    override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
+    }
+
+    override suspend fun deleteLogs(dataset: FlightRecorderDataSet) {
+        fileManager.delete(
+            files = dataset
+                .data
+                .map { File(File(fileManager.logsDirectory), it.fileName) }
+                .toTypedArray(),
+        )
+    }
+
+    override suspend fun getOrCreateLogFile(
+        data: FlightRecorderDataSet.FlightRecorderData,
+    ): Result<File> = withContext(dispatcherManager.io) {
+        runCatching {
+            val logFolder = File(fileManager.logsDirectory)
+            if (!logFolder.exists()) logFolder.mkdirs()
+            val logFile = File(logFolder, data.fileName)
+            if (!logFile.exists()) {
+                logFile.createNewFile()
+                val startTime = Instant
+                    .ofEpochMilli(data.startTimeMs)
+                    .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+                // Upon creating the new file, we pre-populate it with basic data
+                BufferedWriter(FileWriter(logFile, true)).use { bw ->
+                    bw.append("Bitwarden Android")
+                    bw.newLine()
+                    bw.append("Log Start Time: $startTime")
+                    bw.newLine()
+                    bw.append("Log Duration: ${data.durationMs.milliseconds}")
+                    bw.newLine()
+                    bw.append(
+                        "App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
+                    )
+                    bw.newLine()
+                    bw.append("Build: ${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}")
+                    bw.newLine()
+                    bw.append(
+                        "Operating System: ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})"
+                    )
+                    bw.newLine()
+                    bw.append("Device: ${Build.BRAND} ${Build.MODEL}")
+                    bw.newLine()
+                }
+            }
+            logFile
+        }
+    }
+
+    override suspend fun writeToLog(
+        data: FlightRecorderDataSet.FlightRecorderData,
+        priority: Int,
+        tag: String?,
+        message: String,
+        throwable: Throwable?,
+    ) {
+        val logFile = getOrCreateLogFile(data = data).getOrNull() ?: return
+        val formattedTime = clock
+            .instant()
+            .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+        withContext(context = dispatcherManager.io) {
+            BufferedWriter(FileWriter(logFile, true)).use { bw ->
path: app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt
user: login: david-livefront
body: `use` will catch all the exceptions. Unless the concern is that the `BufferedWriter` of `FileWriter` will throw during construction?

As for full disk space, I'm not sure about that. It's not in the spec but I can bring it up
created_at: 2025-04-11T14:46:28Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,141 @@
+package com.x8bit.bitwarden.data.platform.manager.flightrecorder
+
+import android.os.Build
+import android.util.Log
+import com.bitwarden.core.annotation.OmitFromCoverage
+import com.bitwarden.data.manager.DispatcherManager
+import com.x8bit.bitwarden.BuildConfig
+import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
+import com.x8bit.bitwarden.data.vault.manager.FileManager
+import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.BufferedWriter
+import java.io.File
+import java.io.FileWriter
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.time.Clock
+import java.time.Instant
+import kotlin.time.Duration.Companion.milliseconds
+
+private const val LOG_TIME_PATTERN: String = "yyyy-MM-dd HH:mm:ss:SSS"
+
+/**
+ * The default implementation of the [FlightRecorderWriter].
+ */
+@OmitFromCoverage
+class FlightRecorderWriterImpl(
+    private val clock: Clock,
+    private val fileManager: FileManager,
+    private val dispatcherManager: DispatcherManager,
+) : FlightRecorderWriter {
+    override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
+        fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
+    }
+
+    override suspend fun deleteLogs(dataset: FlightRecorderDataSet) {
+        fileManager.delete(
+            files = dataset
+                .data
+                .map { File(File(fileManager.logsDirectory), it.fileName) }
+                .toTypedArray(),
+        )
+    }
+
+    override suspend fun getOrCreateLogFile(
+        data: FlightRecorderDataSet.FlightRecorderData,
+    ): Result<File> = withContext(dispatcherManager.io) {
+        runCatching {
+            val logFolder = File(fileManager.logsDirectory)
+            if (!logFolder.exists()) logFolder.mkdirs()
+            val logFile = File(logFolder, data.fileName)
+            if (!logFile.exists()) {
+                logFile.createNewFile()
+                val startTime = Instant
+                    .ofEpochMilli(data.startTimeMs)
+                    .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+                // Upon creating the new file, we pre-populate it with basic data
+                BufferedWriter(FileWriter(logFile, true)).use { bw ->
+                    bw.append("Bitwarden Android")
+                    bw.newLine()
+                    bw.append("Log Start Time: $startTime")
+                    bw.newLine()
+                    bw.append("Log Duration: ${data.durationMs.milliseconds}")
+                    bw.newLine()
+                    bw.append(
+                        "App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
+                    )
+                    bw.newLine()
+                    bw.append("Build: ${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}")
+                    bw.newLine()
+                    bw.append(
+                        "Operating System: ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})"
+                    )
+                    bw.newLine()
+                    bw.append("Device: ${Build.BRAND} ${Build.MODEL}")
+                    bw.newLine()
+                }
+            }
+            logFile
+        }
+    }
+
+    override suspend fun writeToLog(
+        data: FlightRecorderDataSet.FlightRecorderData,
+        priority: Int,
+        tag: String?,
+        message: String,
+        throwable: Throwable?,
+    ) {
+        val logFile = getOrCreateLogFile(data = data).getOrNull() ?: return
+        val formattedTime = clock
+            .instant()
+            .toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
+        withContext(context = dispatcherManager.io) {
+            BufferedWriter(FileWriter(logFile, true)).use { bw ->
path: app/src/main/java/com/x8bit/bitwarden/data/platform/manager/flightrecorder/FlightRecorderWriterImpl.kt
user: login: david-livefront
body: We discussed offline that `use` will actually re-throw the error, so I have added a `runCatching` here.
created_at: 2025-04-11T15:43:40Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-10T23:09:58Z
body: ![Logo](https://cdn.ast.checkmarx.net/integrations/logo/Checkmarx.png)
**Checkmarx One – Scan Summary & Details** – [70aedf4e\-7cd5\-4b74\-85f2\-a61a13693516](https://ast.checkmarx.net/projects/7b48c130-9c07-4ac2-a6dd-15ed4933d967/scans?id=70aedf4e-7cd5-4b74-85f2-a61a13693516&branch=PM-20133-logging-logic)
### Great job, no security vulnerabilities found in this Pull Request

author_association: CONTRIBUTOR
user: login: david-livefront
created_at: 2025-04-11T15:43:46Z
body: Thanks @SaintPatrck 
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2025-04-11T17:17:56Z
body: ## [Codecov](https://app.codecov.io/gh/bitwarden/android/pull/5029?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) Report
Attention: Patch coverage is `75.42373%` with `29 lines` in your changes missing coverage. Please review.
> Project coverage is 81.18%. Comparing base [(`ed97695`)](https://app.codecov.io/gh/bitwarden/android/commit/ed9769522852dd6aa78134ada51e7b66d020ed88?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) to head [(`70fcb70`)](https://app.codecov.io/gh/bitwarden/android/commit/70fcb707858c2151245947e92395d6bec069d531?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden).
> Report is 2 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/bitwarden/android/pull/5029?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) | Patch % | Lines |
|---|---|---|
| [...manager/flightrecorder/FlightRecorderWriterImpl.kt](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&filepath=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fx8bit%2Fbitwarden%2Fdata%2Fplatform%2Fmanager%2Fflightrecorder%2FFlightRecorderWriterImpl.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden#diff-YXBwL3NyYy9tYWluL2phdmEvY29tL3g4Yml0L2JpdHdhcmRlbi9kYXRhL3BsYXRmb3JtL21hbmFnZXIvZmxpZ2h0cmVjb3JkZXIvRmxpZ2h0UmVjb3JkZXJXcml0ZXJJbXBsLmt0) | 0.00% | [14 Missing :warning: ](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) |
| [.../data/platform/manager/di/PlatformManagerModule.kt](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&filepath=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fx8bit%2Fbitwarden%2Fdata%2Fplatform%2Fmanager%2Fdi%2FPlatformManagerModule.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden#diff-YXBwL3NyYy9tYWluL2phdmEvY29tL3g4Yml0L2JpdHdhcmRlbi9kYXRhL3BsYXRmb3JtL21hbmFnZXIvZGkvUGxhdGZvcm1NYW5hZ2VyTW9kdWxlLmt0) | 0.00% | [10 Missing :warning: ](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) |
| [...anager/flightrecorder/FlightRecorderManagerImpl.kt](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&filepath=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fx8bit%2Fbitwarden%2Fdata%2Fplatform%2Fmanager%2Fflightrecorder%2FFlightRecorderManagerImpl.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden#diff-YXBwL3NyYy9tYWluL2phdmEvY29tL3g4Yml0L2JpdHdhcmRlbi9kYXRhL3BsYXRmb3JtL21hbmFnZXIvZmxpZ2h0cmVjb3JkZXIvRmxpZ2h0UmVjb3JkZXJNYW5hZ2VySW1wbC5rdA==) | 95.65% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) |
| [...platform/repository/di/PlatformRepositoryModule.kt](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&filepath=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fx8bit%2Fbitwarden%2Fdata%2Fplatform%2Frepository%2Fdi%2FPlatformRepositoryModule.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden#diff-YXBwL3NyYy9tYWluL2phdmEvY29tL3g4Yml0L2JpdHdhcmRlbi9kYXRhL3BsYXRmb3JtL3JlcG9zaXRvcnkvZGkvUGxhdGZvcm1SZXBvc2l0b3J5TW9kdWxlLmt0) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/bitwarden/android/pull/5029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5029      +/-   ##
==========================================
- Coverage   83.85%   81.18%   -2.67%     
==========================================
  Files         591     1041     +450     
  Lines       48078    58435   +10357     
  Branches     6642     7186     +544     
==========================================
+ Hits        40315    47442    +7127     
- Misses       5509     8493    +2984     
- Partials     2254     2500     +246     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/bitwarden/android/pull/5029?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bitwarden).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE