Refactor code to export the debug log
This commit is contained in:
parent
5e741a4d56
commit
63364b5c30
13 changed files with 485 additions and 30 deletions
|
@ -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
|
||||
)
|
||||
|
|
8
app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt
Normal file
8
app/core/src/main/java/com/fsck/k9/logging/KoinModule.kt
Normal 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()) }
|
||||
}
|
38
app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt
Normal file
38
app/core/src/main/java/com/fsck/k9/logging/LogFileWriter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
17
app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt
Normal file
17
app/ui/legacy/src/main/java/com/fsck/k9/ui/FlowExtensions.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
10
build.gradle
10
build.gradle
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue