Skip to content

Commit

Permalink
Add save all transactions functionality (#1214)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
irakliy01 committed May 8, 2024
1 parent f8a76b1 commit 9a24876
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,19 +26,25 @@ 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
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(),
Expand All @@ -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)

Expand Down Expand Up @@ -111,6 +128,7 @@ internal class MainActivity :
) == PackageManager.PERMISSION_GRANTED -> {
// We have permission, all good
}

shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
Snackbar.make(
mainBinding.root,
Expand All @@ -125,6 +143,7 @@ internal class MainActivity :
}
}.show()
}

else -> {
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
}
Expand All @@ -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)
}
Expand All @@ -156,6 +176,7 @@ internal class MainActivity :
)
true
}

R.id.share_text -> {
showDialog(
getExportDialogData(R.string.chucker_export_text_http_confirmation),
Expand All @@ -168,6 +189,7 @@ internal class MainActivity :
)
true
}

R.id.share_har -> {
showDialog(
getExportDialogData(R.string.chucker_export_har_http_confirmation),
Expand All @@ -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)
}
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,14 +29,16 @@ 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
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

Expand All @@ -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
Expand Down Expand Up @@ -232,6 +240,7 @@ internal class TransactionPayloadFragment :
PayloadType.REQUEST -> {
(false == transaction?.isRequestBodyEncoded) && (0L != (transaction.requestPayloadSize))
}

PayloadType.RESPONSE -> {
(false == transaction?.isResponseBodyEncoded) && (0L != (transaction.responsePayloadSize))
}
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion library/src/main/res/menu/chucker_transaction.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</item>
<item
android:icon="@drawable/chucker_ic_save_white"
android:title="@string/chucker_save"
android:title="@string/chucker_save_body"
android:id="@+id/save_body"
android:visible="false"
app:showAsAction="ifRoom">
Expand Down
12 changes: 11 additions & 1 deletion library/src/main/res/menu/chucker_transactions_list.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@
android:title="@string/chucker_export"
app:showAsAction="ifRoom">
<menu>
<group>
<group
android:id="@+id/share_group">
<item
android:id="@+id/share_text"
android:title="@string/chucker_share_as_text" />
<item
android:id="@+id/share_har"
android:title="@string/chucker_share_as_har" />
</group>
<group
android:id="@+id/save_group">
<item
android:id="@+id/save_text"
android:title="@string/chucker_save_as_text" />
<item
android:id="@+id/save_har"
android:title="@string/chucker_save_as_har" />
</group>
</menu>
</item>
<item
Expand Down

0 comments on commit 9a24876

Please sign in to comment.