From d7f771990e6f61eae067d0d966ee52a45aeb394b Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 10 Apr 2025 15:57:24 -0500 Subject: [PATCH] PM-20123: Add flight recorder metadata (#5024) --- .../datasource/disk/SettingsDiskSource.kt | 11 ++ .../datasource/disk/SettingsDiskSourceImpl.kt | 18 ++++ .../disk/model/FlightRecorderDataSet.kt | 46 ++++++++ .../datasource/disk/SettingsDiskSourceTest.kt | 100 ++++++++++++++++++ .../disk/util/FakeSettingsDiskSource.kt | 16 +++ 5 files changed, 191 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index 84d6365e12..e657cc27cf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.platform.datasource.disk +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -84,6 +85,16 @@ interface SettingsDiskSource { */ val hasUserLoggedInOrCreatedAccountFlow: Flow + /** + * The current status of whether the flight recorder is enabled. + */ + var flightRecorderData: FlightRecorderDataSet? + + /** + * Emits updates that track [flightRecorderData]. + */ + val flightRecorderDataFlow: Flow + /** * Clears all the settings data for the given user. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 1fdbffee25..813973c632 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -5,6 +5,7 @@ import androidx.core.content.edit import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.data.datasource.disk.BaseDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -45,6 +46,7 @@ private const val CREATE_ACTION_COUNT = "createActionCount" private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark" private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark" private const val RESUME_SCREEN = "resumeScreen" +private const val FLIGHT_RECORDER_KEY = "flightRecorderData" /** * Primary implementation of [SettingsDiskSource]. @@ -84,6 +86,8 @@ class SettingsDiskSourceImpl( private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow() + private val mutableFlightRecorderDataFlow = bufferedMutableSharedFlow() + private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow() private val mutableHasSeenGeneratorCoachMarkFlow = bufferedMutableSharedFlow() @@ -189,6 +193,20 @@ class SettingsDiskSourceImpl( get() = mutableHasUserLoggedInOrCreatedAccountFlow .onSubscription { emit(getBoolean(HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY)) } + override var flightRecorderData: FlightRecorderDataSet? + get() = getString(key = FLIGHT_RECORDER_KEY) + ?.let { json.decodeFromStringOrNull(it) } + set(value) { + putString( + key = FLIGHT_RECORDER_KEY, + value = value?.let { json.encodeToString(it) }, + ) + mutableFlightRecorderDataFlow.tryEmit(value) + } + + override val flightRecorderDataFlow: Flow + get() = mutableFlightRecorderDataFlow.onSubscription { emit(flightRecorderData) } + override fun clearData(userId: String) { storeVaultTimeoutInMinutes(userId = userId, vaultTimeoutInMinutes = null) storeVaultTimeoutAction(userId = userId, vaultTimeoutAction = null) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt new file mode 100644 index 0000000000..f4d24f3043 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/model/FlightRecorderDataSet.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Persistable class containing all required data for a all instances of the flight recorder. + */ +@Serializable +data class FlightRecorderDataSet( + @SerialName("data") + val data: Set, +) { + /** + * Returns `true` is there is an active [FlightRecorderDataSet.FlightRecorderData]. + */ + val hasActiveFlightRecorderData: Boolean + get() = this.activeFlightRecorderData != null + + /** + * Retrieves tha active log if available. + */ + val activeFlightRecorderData: FlightRecorderData? + get() = this.data.find { it.isActive } + + /** + * Persistable class containing all required data for a single instance of the flight recorder. + */ + @Serializable + data class FlightRecorderData( + @SerialName("id") + val id: String, + + @SerialName("fileName") + val fileName: String, + + @SerialName("startTime") + val startTimeMs: Long, + + @SerialName("duration") + val durationMs: Long, + + @SerialName("isActive") + val isActive: Boolean, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 9dca3cf519..447ad89a9b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -5,10 +5,12 @@ import app.cash.turbine.test import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.core.di.CoreModule import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction +import com.x8bit.bitwarden.data.util.assertJsonEquals import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.test.runTest @@ -83,6 +85,104 @@ class SettingsDiskSourceTest { ) } + @Test + fun `flightRecorderData should pull from SharedPreferences`() { + val flightRecorderKey = "bwPreferencesStorage:flightRecorderData" + val encodedData = """ + { + "data": [ + { + "id": "51" + "fileName": "flight_recorder_2025-04-03_14-22-40", + "startTime": 1744059882, + "duration": 3600, + "isActive": false + } + ] + } + """ + .trimIndent() + val expected = FlightRecorderDataSet( + data = setOf( + FlightRecorderDataSet.FlightRecorderData( + id = "51", + fileName = "flight_recorder_2025-04-03_14-22-40", + startTimeMs = 1_744_059_882L, + durationMs = 3_600L, + isActive = false, + ), + ), + ) + + // Verify initial value is null and disk source matches shared preferences. + assertNull(fakeSharedPreferences.getString(flightRecorderKey, null)) + assertNull(settingsDiskSource.flightRecorderData) + + // Updating the shared preferences should update disk source. + fakeSharedPreferences.edit { putString(flightRecorderKey, encodedData) } + val actual = settingsDiskSource.flightRecorderData + assertEquals(expected, actual) + } + + @Test + fun `flightRecorderDataFlow should react to changes in isFLightRecorderEnabled`() = runTest { + val expected = FlightRecorderDataSet( + data = setOf( + FlightRecorderDataSet.FlightRecorderData( + id = "52", + fileName = "flight_recorder_2025-04-03_14-22-40", + startTimeMs = 1_744_059_882L, + durationMs = 3_600L, + isActive = true, + ), + ), + ) + settingsDiskSource.flightRecorderDataFlow.test { + // The initial values of the Flow and the property are in sync + assertNull(settingsDiskSource.flightRecorderData) + assertNull(awaitItem()) + + settingsDiskSource.flightRecorderData = expected + assertEquals(expected, awaitItem()) + + settingsDiskSource.flightRecorderData = null + assertNull(awaitItem()) + } + } + + @Test + fun `setting flightRecorderData should update SharedPreferences`() { + val flightRecorderKey = "bwPreferencesStorage:flightRecorderData" + val data = FlightRecorderDataSet( + data = setOf( + FlightRecorderDataSet.FlightRecorderData( + id = "53", + fileName = "flight_recorder_2025-04-03_14-22-40", + startTimeMs = 1_744_059_882L, + durationMs = 3_600L, + isActive = true, + ), + ), + ) + val expected = """ + { + "data": [ + { + "id": "53", + "fileName": "flight_recorder_2025-04-03_14-22-40", + "startTime": 1744059882, + "duration": 3600, + "isActive": true + } + ] + } + """ + .trimIndent() + settingsDiskSource.flightRecorderData = data + val actual = fakeSharedPreferences.getString(flightRecorderKey, null) + assertJsonEquals(expected, actual!!) + } + @Test fun `systemBiometricIntegritySource should pull from SharedPreferences`() { val biometricIntegritySource = "bwPreferencesStorage:biometricIntegritySource" 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 114d43750b..8eb1dfd862 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.util import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.decodeFromStringOrNull 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.manager.model.AppResumeScreenData import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction @@ -50,6 +51,9 @@ class FakeSettingsDiskSource : SettingsDiskSource { private val mutableShouldShowGeneratorCoachMarkFlow = bufferedMutableSharedFlow() + private val mutableFlightRecorderDataFlow = + bufferedMutableSharedFlow(replay = 1) + private var storedAppLanguage: AppLanguage? = null private var storedAppTheme: AppTheme = AppTheme.DEFAULT private val storedLastSyncTime = mutableMapOf() @@ -80,6 +84,7 @@ class FakeSettingsDiskSource : SettingsDiskSource { private var createSendActionCount: Int? = null private var hasSeenAddLoginCoachMark: Boolean? = null private var hasSeenGeneratorCoachMark: Boolean? = null + private var storedFlightRecorderData: FlightRecorderDataSet? = null private val mutableShowAutoFillSettingBadgeFlowMap = mutableMapOf>() @@ -173,6 +178,17 @@ class FakeSettingsDiskSource : SettingsDiskSource { emit(hasUserLoggedInOrCreatedAccount) } + override var flightRecorderData: FlightRecorderDataSet? + get() = storedFlightRecorderData + set(value) { + storedFlightRecorderData = value + mutableFlightRecorderDataFlow.tryEmit(value) + } + + override val flightRecorderDataFlow: Flow + get() = mutableFlightRecorderDataFlow + .onSubscription { emit(storedFlightRecorderData) } + override fun getAccountBiometricIntegrityValidity( userId: String, systemBioIntegrityState: String,