mirror of
https://github.com/bitwarden/android.git
synced 2026-04-27 19:38:42 -05:00
PM-20133: Add initial logging logic (#5029)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,11 @@ interface FileManager {
|
||||
*/
|
||||
val filesDirectory: String
|
||||
|
||||
/**
|
||||
* Absolute path to the private logs storage directory.
|
||||
*/
|
||||
val logsDirectory: String
|
||||
|
||||
/**
|
||||
* Deletes [files] from disk.
|
||||
*/
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user