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">
- 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 = ""
+ }
+}