From 9a2487605d052d20a124e794560716c61e1e1d0e Mon Sep 17 00:00:00 2001 From: Irakliy Kukava Date: Wed, 8 May 2024 14:01:17 +0400 Subject: [PATCH] Add save all transactions functionality (#1214) * Add save-to-file functionality for transactions * Impacted screen: Main transaction list * New UI elements: Save buttons (grouped, separated from share buttons with the divider) * Save options: Text file, HAR file * Internal: Create FileSaver object for file writing * Add test for FileSaver.saveFile method * Add kotlinx-coroutines-test dependency * Test verifies correct file content is written using the provided URI * Update CHANGELOG.md Add a line to the unreleased block about save all transactions to the file * Remove unnecessary private saveToFile method from FileSaver * Add "empty content test" for FileSaverTest * Document FileSaver class * Small refactor --- CHANGELOG.md | 1 + library/build.gradle | 1 + .../chucker/internal/support/FileSaver.kt | 41 ++++++ .../chucker/internal/ui/MainActivity.kt | 120 ++++++++++++++++++ .../transaction/TransactionPayloadFragment.kt | 51 ++++---- .../src/main/res/menu/chucker_transaction.xml | 2 +- .../res/menu/chucker_transactions_list.xml | 12 +- library/src/main/res/values/strings.xml | 8 +- .../chucker/internal/support/FileSaverTest.kt | 67 ++++++++++ 9 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 library/src/main/kotlin/com/chuckerteam/chucker/internal/support/FileSaver.kt create mode 100644 library/src/test/kotlin/com/chuckerteam/chucker/internal/support/FileSaverTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b144c16e3..c6ef734fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Please add your entries according to this format. * Fixed share of curl when header values contain quotes [#1211] ### Added +* Added _save as text_ and _save as .har file_ options to save all transactions [#1214] ### Fixed * Change GSON `TypeToken` creation to allow using Chucker in builds optimized by R8 [#1166] diff --git a/library/build.gradle b/library/build.gradle index b500c366a..a727b6071 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -79,6 +79,7 @@ dependencies { ksp "androidx.room:room-compiler:$roomVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion" implementation "com.google.code.gson:gson:$gsonVersion" diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/FileSaver.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/FileSaver.kt new file mode 100644 index 000000000..38aae2dbc --- /dev/null +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/support/FileSaver.kt @@ -0,0 +1,41 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.ContentResolver +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.Source +import okio.buffer +import okio.sink + +/** + * Utility class to save a file from a [Source] to a [Uri]. + */ +public object FileSaver { + /** + * Saves the data from the [source] to the file at the [uri] using the [contentResolver]. + * + * @param source The source of the data to save. + * @param uri The URI of the file to save the data to. + * @param contentResolver The content resolver to use to save the data. + * @return `true` if the data was saved successfully, `false` otherwise. + */ + public suspend fun saveFile( + source: Source, + uri: Uri, + contentResolver: ContentResolver, + ): Boolean = + withContext(Dispatchers.IO) { + runCatching { + contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.sink().buffer().use { sink -> + sink.writeAll(source) + } + } + }.onFailure { + Logger.error("Failed to save data to a file", it) + return@withContext false + } + return@withContext true + } +} diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt index d78dc7956..20530f023 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat +import androidx.core.view.MenuCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration @@ -25,6 +26,7 @@ import com.chuckerteam.chucker.api.Chucker import com.chuckerteam.chucker.databinding.ChuckerActivityMainBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction import com.chuckerteam.chucker.internal.data.model.DialogData +import com.chuckerteam.chucker.internal.support.FileSaver import com.chuckerteam.chucker.internal.support.HarUtils import com.chuckerteam.chucker.internal.support.Logger import com.chuckerteam.chucker.internal.support.Sharable @@ -32,12 +34,17 @@ import com.chuckerteam.chucker.internal.support.TransactionDetailsHarSharable import com.chuckerteam.chucker.internal.support.TransactionListDetailsSharable import com.chuckerteam.chucker.internal.support.shareAsFile import com.chuckerteam.chucker.internal.support.showDialog +import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.HAR +import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.TEXT import com.chuckerteam.chucker.internal.ui.transaction.TransactionActivity import com.chuckerteam.chucker.internal.ui.transaction.TransactionAdapter import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okio.Source +import okio.buffer +import okio.source internal class MainActivity : BaseChuckerActivity(), @@ -63,6 +70,16 @@ internal class MainActivity : } } + private val saveTextToFile = + registerForActivityResult(ActivityResultContracts.CreateDocument(TEXT.mimeType)) { uri -> + onSaveToFileActivityResult(uri, TEXT) + } + + private val saveHarToFile = + registerForActivityResult(ActivityResultContracts.CreateDocument(HAR.mimeType)) { uri -> + onSaveToFileActivityResult(uri, HAR) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -111,6 +128,7 @@ internal class MainActivity : ) == PackageManager.PERMISSION_GRANTED -> { // We have permission, all good } + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { Snackbar.make( mainBinding.root, @@ -125,6 +143,7 @@ internal class MainActivity : } }.show() } + else -> { permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS) } @@ -133,6 +152,7 @@ internal class MainActivity : override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.chucker_transactions_list, menu) + MenuCompat.setGroupDividerEnabled(menu, true) setUpSearch(menu) return super.onCreateOptionsMenu(menu) } @@ -156,6 +176,7 @@ internal class MainActivity : ) true } + R.id.share_text -> { showDialog( getExportDialogData(R.string.chucker_export_text_http_confirmation), @@ -168,6 +189,7 @@ internal class MainActivity : ) true } + R.id.share_har -> { showDialog( getExportDialogData(R.string.chucker_export_har_http_confirmation), @@ -186,6 +208,17 @@ internal class MainActivity : ) true } + + R.id.save_text -> { + showSaveDialog(TEXT) + true + } + + R.id.save_har -> { + showSaveDialog(HAR) + true + } + else -> { super.onOptionsItemSelected(item) } @@ -248,6 +281,93 @@ internal class MainActivity : negativeButtonText = getString(R.string.chucker_cancel), ) + private fun getSaveDialogData( + @StringRes dialogMessage: Int, + ): DialogData = + DialogData( + title = getString(R.string.chucker_save), + message = getString(dialogMessage), + positiveButtonText = getString(R.string.chucker_save), + negativeButtonText = getString(R.string.chucker_cancel), + ) + + private fun showSaveDialog(exportType: ExportType) { + showDialog( + getSaveDialogData( + when (exportType) { + TEXT -> R.string.chucker_save_text_http_confirmation + HAR -> R.string.chucker_save_har_http_confirmation + }, + ), + onPositiveClick = { + when (exportType) { + TEXT -> saveTextToFile.launch(EXPORT_TXT_FILE_NAME) + HAR -> saveHarToFile.launch(EXPORT_HAR_FILE_NAME) + } + }, + onNegativeClick = null, + ) + } + + private fun onSaveToFileActivityResult( + uri: Uri?, + exportType: ExportType, + ) { + if (uri == null) { + Toast.makeText( + applicationContext, + R.string.chucker_save_failed_to_open_document, + Toast.LENGTH_SHORT, + ).show() + return + } + lifecycleScope.launch { + val source = + runCatching { + prepareDataToSave(exportType) + }.getOrNull() ?: return@launch + val result = FileSaver.saveFile(source, uri, contentResolver) + val toastMessageId = + if (result) { + R.string.chucker_file_saved + } else { + R.string.chucker_file_not_saved + } + Toast.makeText(applicationContext, toastMessageId, Toast.LENGTH_SHORT).show() + } + } + + private suspend fun prepareDataToSave(exportType: ExportType): Source? { + val transactions = viewModel.getAllTransactions() + if (transactions.isEmpty()) { + showToast(applicationContext.getString(R.string.chucker_save_empty_text)) + return null + } + return withContext(Dispatchers.IO) { + when (exportType) { + TEXT -> { + TransactionListDetailsSharable( + transactions, + encodeUrls = false, + ).toSharableContent(this@MainActivity) + } + + HAR -> { + HarUtils.harStringFromTransactions( + transactions, + getString(R.string.chucker_name), + getString(R.string.chucker_version), + ).byteInputStream().source().buffer() + } + } + } + } + + private enum class ExportType(val mimeType: String) { + TEXT("text/plain"), + HAR("application/har+json"), + } + companion object { private const val EXPORT_TXT_FILE_NAME = "transactions.txt" private const val EXPORT_HAR_FILE_NAME = "transactions.har" diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt index 5c3596616..d7b262099 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt @@ -6,7 +6,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.graphics.Color -import android.net.Uri import android.os.Bundle import android.text.SpannableStringBuilder import android.view.LayoutInflater @@ -30,6 +29,7 @@ import androidx.lifecycle.withResumed import com.chuckerteam.chucker.R import com.chuckerteam.chucker.databinding.ChuckerFragmentTransactionPayloadBinding import com.chuckerteam.chucker.internal.data.entity.HttpTransaction +import com.chuckerteam.chucker.internal.support.FileSaver import com.chuckerteam.chucker.internal.support.Logger import com.chuckerteam.chucker.internal.support.calculateLuminance import com.chuckerteam.chucker.internal.support.combineLatest @@ -37,7 +37,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.io.FileOutputStream +import okio.Source +import okio.source import java.io.IOException import kotlin.math.abs @@ -55,7 +56,14 @@ internal class TransactionPayloadFragment : val applicationContext = requireContext().applicationContext if (uri != null && transaction != null) { lifecycleScope.launch { - val result = saveToFile(payloadType, uri, transaction) + val source = + runCatching { + prepareDataToSave(payloadType, transaction) + }.getOrElse { + Logger.error("Failed to save transaction to a file", it) + return@launch + } + val result = FileSaver.saveFile(source, uri, applicationContext.contentResolver) val toastMessageId = if (result) { R.string.chucker_file_saved @@ -232,6 +240,7 @@ internal class TransactionPayloadFragment : PayloadType.REQUEST -> { (false == transaction?.isRequestBodyEncoded) && (0L != (transaction.requestPayloadSize)) } + PayloadType.RESPONSE -> { (false == transaction?.isResponseBodyEncoded) && (0L != (transaction.responsePayloadSize)) } @@ -415,35 +424,21 @@ internal class TransactionPayloadFragment : } } - private suspend fun saveToFile( + private fun prepareDataToSave( type: PayloadType, - uri: Uri, transaction: HttpTransaction, - ): Boolean { - return withContext(Dispatchers.IO) { - try { - requireContext().contentResolver.openFileDescriptor(uri, "w")?.use { - FileOutputStream(it.fileDescriptor).use { fos -> - when (type) { - PayloadType.REQUEST -> { - transaction.requestBody?.byteInputStream()?.copyTo(fos) - ?: throw IOException(TRANSACTION_EXCEPTION) - } - - PayloadType.RESPONSE -> { - transaction.responseBody?.byteInputStream()?.copyTo(fos) - ?: throw IOException(TRANSACTION_EXCEPTION) - } - } - } - } - } catch (e: IOException) { - Logger.error("Failed to save transaction to a file", e) - return@withContext false + ): Source = + when (type) { + PayloadType.REQUEST -> { + transaction.requestBody?.byteInputStream()?.source() + ?: throw IOException(TRANSACTION_EXCEPTION) + } + + PayloadType.RESPONSE -> { + transaction.responseBody?.byteInputStream()?.source() + ?: throw IOException(TRANSACTION_EXCEPTION) } - return@withContext true } - } private fun isBodyEmpty( type: PayloadType, diff --git a/library/src/main/res/menu/chucker_transaction.xml b/library/src/main/res/menu/chucker_transaction.xml index 422e5137c..236fbd568 100644 --- a/library/src/main/res/menu/chucker_transaction.xml +++ b/library/src/main/res/menu/chucker_transaction.xml @@ -38,7 +38,7 @@ diff --git a/library/src/main/res/menu/chucker_transactions_list.xml b/library/src/main/res/menu/chucker_transactions_list.xml index dbb77088c..d0fe83a89 100644 --- a/library/src/main/res/menu/chucker_transactions_list.xml +++ b/library/src/main/res/menu/chucker_transactions_list.xml @@ -13,7 +13,8 @@ android:title="@string/chucker_export" app:showAsAction="ifRoom"> - + @@ -21,6 +22,15 @@ android:id="@+id/share_har" android:title="@string/chucker_share_as_har" /> + + + + No Share Export + Save Nothing to export Failed to create file for export Share as text Share as curl command Share as file Share as .har file - Save body to file + Save as text + Save as .har file + Save body to file + Nothing to save Failed to open file chooser File saved successfully! Failed to save file @@ -49,6 +53,8 @@ Do you want to clear complete network calls history? Do you want to export all network transactions as a text file? Do you want to export all network transactions as a .har file? + Do you want to save all network transactions as a text file? + Do you want to save all network transactions as a .har file? ================== /* Export Start */ /* Export End */ diff --git a/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/FileSaverTest.kt b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/FileSaverTest.kt new file mode 100644 index 000000000..a4511dba3 --- /dev/null +++ b/library/src/test/kotlin/com/chuckerteam/chucker/internal/support/FileSaverTest.kt @@ -0,0 +1,67 @@ +package com.chuckerteam.chucker.internal.support + +import android.content.ContentResolver +import android.net.Uri +import androidx.core.net.toFile +import com.chuckerteam.chucker.util.NoLoggerRule +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okio.source +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.rules.TemporaryFolder + +@ExtendWith(NoLoggerRule::class) +internal class FileSaverTest { + @Rule + @JvmField + val temporaryFolder = TemporaryFolder() + + init { + temporaryFolder.create() + } + + private val file = temporaryFolder.newFile(TEST_FILENAME) + + private val uri = + mockk { + every { scheme } returns TEST_FILE_SCHEME + every { path } returns file.path + } + private val contentResolver = + mockk { + every { openOutputStream(any()) } returns uri.toFile().outputStream() + } + + @Test + fun `normal content test`() = + runTest { + val source = TEST_FILE_CONTENT.byteInputStream().source() + + FileSaver.saveFile(source, uri, contentResolver) + + assertThat(file.length()).isEqualTo(TEST_FILE_CONTENT.length) + assertThat(file.readText()).isEqualTo(TEST_FILE_CONTENT) + } + + @Test + fun `empty content test`() = + runTest { + val source = TEST_EMPTY_FILE_CONTENT.byteInputStream().source() + + FileSaver.saveFile(source, uri, contentResolver) + + assertThat(file.length()).isEqualTo(TEST_EMPTY_FILE_CONTENT.length) + assertThat(file.readText()).isEqualTo(TEST_EMPTY_FILE_CONTENT) + } + + private companion object { + private const val TEST_FILENAME = "test_file" + private const val TEST_FILE_SCHEME = "file" + private const val TEST_FILE_CONTENT = "Hello world!" + private const val TEST_EMPTY_FILE_CONTENT = "" + } +}