Refactor code to export the debug log

This commit is contained in:
cketti 2021-10-30 02:31:37 +02:00
parent 5e741a4d56
commit 63364b5c30
13 changed files with 485 additions and 30 deletions

View file

@ -6,6 +6,7 @@ import com.fsck.k9.controller.push.controllerPushModule
import com.fsck.k9.crypto.openPgpModule
import com.fsck.k9.helper.helperModule
import com.fsck.k9.job.jobModule
import com.fsck.k9.logging.loggingModule
import com.fsck.k9.mailstore.mailStoreModule
import com.fsck.k9.message.extractors.extractorModule
import com.fsck.k9.message.html.htmlModule
@ -32,5 +33,6 @@ val coreModules = listOf(
helperModule,
preferencesModule,
connectivityModule,
powerModule
powerModule,
loggingModule
)

View file

@ -0,0 +1,8 @@
package com.fsck.k9.logging
import org.koin.dsl.module
val loggingModule = module {
factory<ProcessExecutor> { RealProcessExecutor() }
factory<LogFileWriter> { LogcatLogFileWriter(contentResolver = get(), processExecutor = get()) }
}

View file

@ -0,0 +1,38 @@
package com.fsck.k9.logging
import android.content.ContentResolver
import android.net.Uri
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import timber.log.Timber
interface LogFileWriter {
suspend fun writeLogTo(contentUri: Uri)
}
class LogcatLogFileWriter(
private val contentResolver: ContentResolver,
private val processExecutor: ProcessExecutor,
private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO
) : LogFileWriter {
override suspend fun writeLogTo(contentUri: Uri) {
return withContext(coroutineDispatcher) {
writeLogBlocking(contentUri)
}
}
private fun writeLogBlocking(contentUri: Uri) {
Timber.v("Writing logcat output to content URI: %s", contentUri)
val outputStream = contentResolver.openOutputStream(contentUri)
?: error("Error opening contentUri for writing")
outputStream.use {
processExecutor.exec("logcat -d").use { inputStream ->
IOUtils.copy(inputStream, outputStream)
}
}
}
}

View file

@ -0,0 +1,14 @@
package com.fsck.k9.logging
import java.io.InputStream
interface ProcessExecutor {
fun exec(command: String): InputStream
}
class RealProcessExecutor : ProcessExecutor {
override fun exec(command: String): InputStream {
val process = Runtime.getRuntime().exec(command)
return process.inputStream
}
}

View file

@ -0,0 +1,85 @@
package com.fsck.k9.logging
import android.content.ContentResolver
import android.net.Uri
import com.google.common.truth.Truth.assertThat
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
class LogcatLogFileWriterTest {
private val contentUri = mock<Uri>()
private val outputStream = ByteArrayOutputStream()
@Test
fun `write log to contentUri`() = runBlocking {
val logData = "a".repeat(10_000)
val logFileWriter = LogcatLogFileWriter(
contentResolver = createContentResolver(),
processExecutor = createProcessExecutor(logData),
coroutineDispatcher = Dispatchers.Unconfined
)
logFileWriter.writeLogTo(contentUri)
assertThat(outputStream.toByteArray().decodeToString()).isEqualTo(logData)
}
@Test(expected = FileNotFoundException::class)
fun `contentResolver throws`() = runBlocking {
val logFileWriter = LogcatLogFileWriter(
contentResolver = createThrowingContentResolver(FileNotFoundException()),
processExecutor = createProcessExecutor("irrelevant"),
coroutineDispatcher = Dispatchers.Unconfined
)
logFileWriter.writeLogTo(contentUri)
}
@Test(expected = IOException::class)
fun `processExecutor throws`() = runBlocking {
val logFileWriter = LogcatLogFileWriter(
contentResolver = createContentResolver(),
processExecutor = ThrowingProcessExecutor(IOException()),
coroutineDispatcher = Dispatchers.Unconfined
)
logFileWriter.writeLogTo(contentUri)
}
private fun createContentResolver(): ContentResolver {
return mock {
on { openOutputStream(contentUri) } doReturn outputStream
}
}
private fun createThrowingContentResolver(exception: Exception): ContentResolver {
return mock {
on { openOutputStream(contentUri) } doAnswer { throw exception }
}
}
private fun createProcessExecutor(logData: String): DataProcessExecutor {
return DataProcessExecutor(logData.toByteArray(charset = Charsets.US_ASCII))
}
}
private class DataProcessExecutor(val data: ByteArray) : ProcessExecutor {
override fun exec(command: String): InputStream {
return ByteArrayInputStream(data)
}
}
private class ThrowingProcessExecutor(val exception: Exception) : ProcessExecutor {
override fun exec(command: String): InputStream {
throw exception
}
}

View file

@ -23,6 +23,7 @@ dependencies {
implementation "com.takisoft.preferencex:preferencex-colorpicker:${versions.preferencesFix}"
implementation "com.takisoft.preferencex:preferencex-ringtone:${versions.preferencesFix}"
implementation "androidx.recyclerview:recyclerview:${versions.androidxRecyclerView}"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidxLifecycle}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidxLifecycle}"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${versions.androidxLifecycle}"
implementation "androidx.constraintlayout:constraintlayout:${versions.androidxConstraintLayout}"
@ -63,6 +64,8 @@ dependencies {
testImplementation "org.mockito:mockito-core:${versions.mockito}"
testImplementation "org.mockito.kotlin:mockito-kotlin:${versions.mockitoKotlin}"
testImplementation "io.insert-koin:koin-test-junit4:${versions.koin}"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.kotlinCoroutines}"
testImplementation "app.cash.turbine:turbine:${versions.turbine}"
}
android {

View file

@ -0,0 +1,17 @@
package com.fsck.k9.ui
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
fun <T> Flow<T>.observe(lifecycleOwner: LifecycleOwner, action: suspend (T) -> Unit) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect(action)
}
}
}

View file

@ -5,6 +5,7 @@ import com.fsck.k9.ui.settings.account.AccountSettingsDataStoreFactory
import com.fsck.k9.ui.settings.account.AccountSettingsViewModel
import com.fsck.k9.ui.settings.export.SettingsExportViewModel
import com.fsck.k9.ui.settings.general.GeneralSettingsDataStore
import com.fsck.k9.ui.settings.general.GeneralSettingsViewModel
import com.fsck.k9.ui.settings.import.AccountActivator
import com.fsck.k9.ui.settings.import.SettingsImportResultViewModel
import com.fsck.k9.ui.settings.import.SettingsImportViewModel
@ -16,6 +17,7 @@ import org.koin.dsl.module
val settingsUiModule = module {
viewModel { SettingsViewModel(accountManager = get()) }
viewModel { GeneralSettingsViewModel(logFileWriter = get()) }
factory { GeneralSettingsDataStore(jobManager = get(), themeManager = get(), appLanguageManager = get()) }
single(named("SaveSettingsExecutorService")) {
Executors.newSingleThreadExecutor(NamedThreadFactory("SaveSettings"))

View file

@ -5,23 +5,29 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.preference.ListPreference
import com.fsck.k9.ui.R
import com.fsck.k9.ui.observe
import com.fsck.k9.ui.withArguments
import com.google.android.material.snackbar.Snackbar
import com.takisoft.preferencex.PreferenceFragmentCompat
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.commons.io.IOUtils
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
class GeneralSettingsFragment : PreferenceFragmentCompat() {
private val viewModel: GeneralSettingsViewModel by viewModel()
private val dataStore: GeneralSettingsDataStore by inject()
private var rootKey: String? = null
private var currentUiState: GeneralSettingsUiState? = null
private var snackbar: Snackbar? = null
private val exportLogsResultContract = registerForActivityResult(CreateDocument()) { contentUri ->
if (contentUri != null) {
viewModel.exportLogs(contentUri)
}
}
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = dataStore
@ -30,6 +36,15 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() {
setPreferencesFromResource(R.xml.general_settings, rootKey)
initializeTheme()
viewModel.uiState.observe(this) { uiState ->
updateUiState(uiState)
}
}
override fun onDestroyView() {
super.onDestroyView()
dismissSnackbar()
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
@ -38,37 +53,21 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() {
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (rootKey == "debug_preferences") {
if (rootKey == PREFERENCE_SCREEN_DEBUGGING) {
inflater.inflate(R.menu.debug_settings_option, menu)
currentUiState?.let { uiState ->
menu.findItem(R.id.exportLogs).isEnabled = uiState.isExportLogsMenuEnabled
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.exportLogs) {
exportLogsResultContract.launch("k9mail-logs.txt")
exportLogsResultContract.launch(GeneralSettingsViewModel.DEFAULT_FILENAME)
return true
}
return super.onOptionsItemSelected(item)
}
private val exportLogsResultContract = registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
if (uri != null) {
lifecycleScope.launch(Dispatchers.IO) {
val message = try {
requireContext().contentResolver.openOutputStream(uri).use { outputFile ->
Runtime.getRuntime().exec("logcat -d").inputStream.use { logOutput ->
IOUtils.copy(logOutput, outputFile)
}
}
getString(R.string.debug_export_logs_success)
} catch (e: IOException) {
e.message.toString()
}
withContext(Dispatchers.Main) {
Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
}
}
}
return super.onOptionsItemSelected(item)
}
private fun initializeTheme() {
@ -80,8 +79,45 @@ class GeneralSettingsFragment : PreferenceFragmentCompat() {
}
}
private fun updateUiState(uiState: GeneralSettingsUiState) {
val oldUiState = currentUiState
currentUiState = uiState
if (oldUiState?.isExportLogsMenuEnabled != uiState.isExportLogsMenuEnabled) {
setExportLogsMenuEnabled()
}
if (oldUiState?.snackbarState != uiState.snackbarState) {
setSnackbarState(uiState.snackbarState)
}
}
private fun setExportLogsMenuEnabled() {
requireActivity().invalidateOptionsMenu()
}
private fun setSnackbarState(snackbarState: SnackbarState) {
when (snackbarState) {
SnackbarState.Hidden -> dismissSnackbar()
SnackbarState.ExportLogSuccess -> showSnackbar(R.string.debug_export_logs_success)
SnackbarState.ExportLogFailure -> showSnackbar(R.string.debug_export_logs_failure)
}
}
private fun dismissSnackbar() {
snackbar?.dismiss()
snackbar = null
}
private fun showSnackbar(message: Int) {
Snackbar.make(requireView(), message, Snackbar.LENGTH_INDEFINITE)
.also { snackbar = it }
.show()
}
companion object {
private const val PREFERENCE_THEME = "theme"
private const val PREFERENCE_SCREEN_DEBUGGING = "debug_preferences"
fun create(rootKey: String? = null) = GeneralSettingsFragment().withArguments(ARG_PREFERENCE_ROOT to rootKey)
}

View file

@ -0,0 +1,91 @@
package com.fsck.k9.ui.settings.general
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fsck.k9.logging.LogFileWriter
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
class GeneralSettingsViewModel(private val logFileWriter: LogFileWriter) : ViewModel() {
private var snackbarJob: Job? = null
private val uiStateFlow = MutableStateFlow<GeneralSettingsUiState>(GeneralSettingsUiState.Idle)
val uiState: Flow<GeneralSettingsUiState> = uiStateFlow
fun exportLogs(contentUri: Uri) {
viewModelScope.launch {
setExportingState()
try {
logFileWriter.writeLogTo(contentUri)
showSnackbar(GeneralSettingsUiState.Success)
} catch (e: Exception) {
Timber.e(e, "Failed to write log to URI: %s", contentUri)
showSnackbar(GeneralSettingsUiState.Failure)
}
}
}
private fun setExportingState() {
// If an export was triggered before and the success/failure Snackbar is still showing, cancel the coroutine
// that resets the state to Idle after SNACKBAR_DURATION
snackbarJob?.cancel()
snackbarJob = null
sendUiState(GeneralSettingsUiState.Exporting)
}
private fun showSnackbar(uiState: GeneralSettingsUiState) {
snackbarJob?.cancel()
snackbarJob = viewModelScope.launch {
sendUiState(uiState)
delay(SNACKBAR_DURATION)
sendUiState(GeneralSettingsUiState.Idle)
snackbarJob = null
}
}
private fun sendUiState(uiState: GeneralSettingsUiState) {
uiStateFlow.value = uiState
}
companion object {
const val DEFAULT_FILENAME = "k9mail-logs.txt"
const val SNACKBAR_DURATION = 3000L
}
}
sealed interface GeneralSettingsUiState {
val isExportLogsMenuEnabled: Boolean
val snackbarState: SnackbarState
object Idle : GeneralSettingsUiState {
override val isExportLogsMenuEnabled = true
override val snackbarState = SnackbarState.Hidden
}
object Exporting : GeneralSettingsUiState {
override val isExportLogsMenuEnabled = false
override val snackbarState = SnackbarState.Hidden
}
object Success : GeneralSettingsUiState {
override val isExportLogsMenuEnabled = true
override val snackbarState = SnackbarState.ExportLogSuccess
}
object Failure : GeneralSettingsUiState {
override val isExportLogsMenuEnabled = true
override val snackbarState = SnackbarState.ExportLogFailure
}
}
enum class SnackbarState {
Hidden,
ExportLogSuccess,
ExportLogFailure
}

View file

@ -247,6 +247,7 @@ Please submit bug reports, contribute new features and ask questions at
<string name="debug_enable_sensitive_logging_summary">May show passwords in logs.</string>
<string name="debug_export_logs_title">Export logs</string>
<string name="debug_export_logs_success">Export successful. Logs might contain sensitive information. Be careful who you send them to.</string>
<string name="debug_export_logs_failure">Export failed.</string>
<string name="message_list_load_more_messages_action">Load more messages</string>
<string name="message_to_fmt">To:<xliff:g id="counterParty">%s</xliff:g></string>

View file

@ -0,0 +1,148 @@
package com.fsck.k9.ui.settings.general
import android.net.Uri
import app.cash.turbine.test
import com.fsck.k9.logging.LogFileWriter
import com.google.common.truth.Truth.assertThat
import java.io.IOException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.mock
@OptIn(ExperimentalCoroutinesApi::class)
class GeneralSettingsViewModelTest {
private val logFileWriter = TestLogFileWriter()
private val contentUri = mock<Uri>()
private val viewModel = GeneralSettingsViewModel(logFileWriter)
private val testCoroutineDispatcher = TestCoroutineDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testCoroutineDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `export logs without errors`() = runBlocking {
viewModel.uiState.test {
viewModel.exportLogs(contentUri)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Exporting)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Success)
testCoroutineDispatcher.advanceTimeBy(GeneralSettingsViewModel.SNACKBAR_DURATION)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(cancelAndConsumeRemainingEvents()).isEmpty()
}
}
@Test
fun `export logs with consumer changing while LogFileWriter_writeLogTo is running`() = runBlocking {
withTimeout(timeMillis = 1000L) {
logFileWriter.shouldWait()
val mutex = Mutex(locked = true)
// The first consumer
val job = launch(CoroutineName("ConsumerOne")) {
var first = true
val states = viewModel.uiState.onEach {
if (first) {
first = false
mutex.unlock()
}
}.take(2).toList()
assertThat(states[0]).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(states[1]).isEqualTo(GeneralSettingsUiState.Exporting)
}
// Wait until the "ConsumerOne" coroutine has collected the initial UI state
mutex.lock()
viewModel.exportLogs(contentUri)
// Wait until the "ConsumerOne" coroutine has finished collecting items
job.join()
// The second consumer
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Exporting)
logFileWriter.resume()
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Success)
testCoroutineDispatcher.advanceTimeBy(GeneralSettingsViewModel.SNACKBAR_DURATION)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(cancelAndConsumeRemainingEvents()).isEmpty()
}
}
}
@Test
fun `export logs with IOException`() = runBlocking {
logFileWriter.exception = IOException()
viewModel.uiState.test {
viewModel.exportLogs(contentUri)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Exporting)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Failure)
testCoroutineDispatcher.advanceTimeBy(GeneralSettingsViewModel.SNACKBAR_DURATION)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(cancelAndConsumeRemainingEvents()).isEmpty()
}
}
@Test
fun `export logs with IllegalStateException`() = runBlocking {
logFileWriter.exception = IllegalStateException()
viewModel.uiState.test {
viewModel.exportLogs(contentUri)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Exporting)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Failure)
testCoroutineDispatcher.advanceTimeBy(GeneralSettingsViewModel.SNACKBAR_DURATION)
assertThat(awaitItem()).isEqualTo(GeneralSettingsUiState.Idle)
assertThat(cancelAndConsumeRemainingEvents()).isEmpty()
}
}
}
class TestLogFileWriter : LogFileWriter {
var exception: Throwable? = null
private var mutex: Mutex? = null
override suspend fun writeLogTo(contentUri: Uri) {
exception?.let { throw it }
mutex?.lock()
}
fun shouldWait() {
mutex = Mutex(locked = true)
}
fun resume() {
mutex!!.unlock()
}
}

View file

@ -47,6 +47,7 @@ buildscript {
'mockito': '4.0.0',
'mockitoKotlin': '4.0.0',
'truth': '1.1.3',
'turbine': '0.7.0',
'ktlint': '0.40.0'
]
@ -81,6 +82,15 @@ subprojects {
maven { url 'https://jitpack.io' }
}
configurations.all {
resolutionStrategy.dependencySubstitution {
substitute module("androidx.core:core") using module("androidx.core:core:${versions.androidxCore}")
substitute module("androidx.activity:activity") using module("androidx.activity:activity:${versions.androidxActivity}")
substitute module("androidx.appcompat:appcompat") using module("androidx.appcompat:appcompat:${versions.androidxAppCompat}")
substitute module("androidx.preference:preference") using module("androidx.preference:preference:${versions.androidxPreference}")
}
}
tasks.withType(Test) {
testLogging {
exceptionFormat "full"