Skip to content

Add save all transactions functionality #1214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is unneceesary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no action on dismiss (old actions also set this listener as 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
Loading
Loading