Skip to content

Commit 9a24876

Browse files
authored
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
1 parent f8a76b1 commit 9a24876

File tree

9 files changed

+272
-31
lines changed

9 files changed

+272
-31
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Please add your entries according to this format.
1010
* Fixed share of curl when header values contain quotes [#1211]
1111

1212
### Added
13+
* Added _save as text_ and _save as .har file_ options to save all transactions [#1214]
1314

1415
### Fixed
1516
* Change GSON `TypeToken` creation to allow using Chucker in builds optimized by R8 [#1166]

library/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ dependencies {
7979
ksp "androidx.room:room-compiler:$roomVersion"
8080

8181
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
82+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion"
8283

8384
implementation "com.google.code.gson:gson:$gsonVersion"
8485

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.chuckerteam.chucker.internal.support
2+
3+
import android.content.ContentResolver
4+
import android.net.Uri
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.withContext
7+
import okio.Source
8+
import okio.buffer
9+
import okio.sink
10+
11+
/**
12+
* Utility class to save a file from a [Source] to a [Uri].
13+
*/
14+
public object FileSaver {
15+
/**
16+
* Saves the data from the [source] to the file at the [uri] using the [contentResolver].
17+
*
18+
* @param source The source of the data to save.
19+
* @param uri The URI of the file to save the data to.
20+
* @param contentResolver The content resolver to use to save the data.
21+
* @return `true` if the data was saved successfully, `false` otherwise.
22+
*/
23+
public suspend fun saveFile(
24+
source: Source,
25+
uri: Uri,
26+
contentResolver: ContentResolver,
27+
): Boolean =
28+
withContext(Dispatchers.IO) {
29+
runCatching {
30+
contentResolver.openOutputStream(uri)?.use { outputStream ->
31+
outputStream.sink().buffer().use { sink ->
32+
sink.writeAll(source)
33+
}
34+
}
35+
}.onFailure {
36+
Logger.error("Failed to save data to a file", it)
37+
return@withContext false
38+
}
39+
return@withContext true
40+
}
41+
}

library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/MainActivity.kt

+120
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.annotation.RequiresApi
1717
import androidx.annotation.StringRes
1818
import androidx.appcompat.widget.SearchView
1919
import androidx.core.content.ContextCompat
20+
import androidx.core.view.MenuCompat
2021
import androidx.core.view.isVisible
2122
import androidx.lifecycle.lifecycleScope
2223
import androidx.recyclerview.widget.DividerItemDecoration
@@ -25,19 +26,25 @@ import com.chuckerteam.chucker.api.Chucker
2526
import com.chuckerteam.chucker.databinding.ChuckerActivityMainBinding
2627
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
2728
import com.chuckerteam.chucker.internal.data.model.DialogData
29+
import com.chuckerteam.chucker.internal.support.FileSaver
2830
import com.chuckerteam.chucker.internal.support.HarUtils
2931
import com.chuckerteam.chucker.internal.support.Logger
3032
import com.chuckerteam.chucker.internal.support.Sharable
3133
import com.chuckerteam.chucker.internal.support.TransactionDetailsHarSharable
3234
import com.chuckerteam.chucker.internal.support.TransactionListDetailsSharable
3335
import com.chuckerteam.chucker.internal.support.shareAsFile
3436
import com.chuckerteam.chucker.internal.support.showDialog
37+
import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.HAR
38+
import com.chuckerteam.chucker.internal.ui.MainActivity.ExportType.TEXT
3539
import com.chuckerteam.chucker.internal.ui.transaction.TransactionActivity
3640
import com.chuckerteam.chucker.internal.ui.transaction.TransactionAdapter
3741
import com.google.android.material.snackbar.Snackbar
3842
import kotlinx.coroutines.Dispatchers
3943
import kotlinx.coroutines.launch
4044
import kotlinx.coroutines.withContext
45+
import okio.Source
46+
import okio.buffer
47+
import okio.source
4148

4249
internal class MainActivity :
4350
BaseChuckerActivity(),
@@ -63,6 +70,16 @@ internal class MainActivity :
6370
}
6471
}
6572

73+
private val saveTextToFile =
74+
registerForActivityResult(ActivityResultContracts.CreateDocument(TEXT.mimeType)) { uri ->
75+
onSaveToFileActivityResult(uri, TEXT)
76+
}
77+
78+
private val saveHarToFile =
79+
registerForActivityResult(ActivityResultContracts.CreateDocument(HAR.mimeType)) { uri ->
80+
onSaveToFileActivityResult(uri, HAR)
81+
}
82+
6683
override fun onCreate(savedInstanceState: Bundle?) {
6784
super.onCreate(savedInstanceState)
6885

@@ -111,6 +128,7 @@ internal class MainActivity :
111128
) == PackageManager.PERMISSION_GRANTED -> {
112129
// We have permission, all good
113130
}
131+
114132
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
115133
Snackbar.make(
116134
mainBinding.root,
@@ -125,6 +143,7 @@ internal class MainActivity :
125143
}
126144
}.show()
127145
}
146+
128147
else -> {
129148
permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
130149
}
@@ -133,6 +152,7 @@ internal class MainActivity :
133152

134153
override fun onCreateOptionsMenu(menu: Menu): Boolean {
135154
menuInflater.inflate(R.menu.chucker_transactions_list, menu)
155+
MenuCompat.setGroupDividerEnabled(menu, true)
136156
setUpSearch(menu)
137157
return super.onCreateOptionsMenu(menu)
138158
}
@@ -156,6 +176,7 @@ internal class MainActivity :
156176
)
157177
true
158178
}
179+
159180
R.id.share_text -> {
160181
showDialog(
161182
getExportDialogData(R.string.chucker_export_text_http_confirmation),
@@ -168,6 +189,7 @@ internal class MainActivity :
168189
)
169190
true
170191
}
192+
171193
R.id.share_har -> {
172194
showDialog(
173195
getExportDialogData(R.string.chucker_export_har_http_confirmation),
@@ -186,6 +208,17 @@ internal class MainActivity :
186208
)
187209
true
188210
}
211+
212+
R.id.save_text -> {
213+
showSaveDialog(TEXT)
214+
true
215+
}
216+
217+
R.id.save_har -> {
218+
showSaveDialog(HAR)
219+
true
220+
}
221+
189222
else -> {
190223
super.onOptionsItemSelected(item)
191224
}
@@ -248,6 +281,93 @@ internal class MainActivity :
248281
negativeButtonText = getString(R.string.chucker_cancel),
249282
)
250283

284+
private fun getSaveDialogData(
285+
@StringRes dialogMessage: Int,
286+
): DialogData =
287+
DialogData(
288+
title = getString(R.string.chucker_save),
289+
message = getString(dialogMessage),
290+
positiveButtonText = getString(R.string.chucker_save),
291+
negativeButtonText = getString(R.string.chucker_cancel),
292+
)
293+
294+
private fun showSaveDialog(exportType: ExportType) {
295+
showDialog(
296+
getSaveDialogData(
297+
when (exportType) {
298+
TEXT -> R.string.chucker_save_text_http_confirmation
299+
HAR -> R.string.chucker_save_har_http_confirmation
300+
},
301+
),
302+
onPositiveClick = {
303+
when (exportType) {
304+
TEXT -> saveTextToFile.launch(EXPORT_TXT_FILE_NAME)
305+
HAR -> saveHarToFile.launch(EXPORT_HAR_FILE_NAME)
306+
}
307+
},
308+
onNegativeClick = null,
309+
)
310+
}
311+
312+
private fun onSaveToFileActivityResult(
313+
uri: Uri?,
314+
exportType: ExportType,
315+
) {
316+
if (uri == null) {
317+
Toast.makeText(
318+
applicationContext,
319+
R.string.chucker_save_failed_to_open_document,
320+
Toast.LENGTH_SHORT,
321+
).show()
322+
return
323+
}
324+
lifecycleScope.launch {
325+
val source =
326+
runCatching {
327+
prepareDataToSave(exportType)
328+
}.getOrNull() ?: return@launch
329+
val result = FileSaver.saveFile(source, uri, contentResolver)
330+
val toastMessageId =
331+
if (result) {
332+
R.string.chucker_file_saved
333+
} else {
334+
R.string.chucker_file_not_saved
335+
}
336+
Toast.makeText(applicationContext, toastMessageId, Toast.LENGTH_SHORT).show()
337+
}
338+
}
339+
340+
private suspend fun prepareDataToSave(exportType: ExportType): Source? {
341+
val transactions = viewModel.getAllTransactions()
342+
if (transactions.isEmpty()) {
343+
showToast(applicationContext.getString(R.string.chucker_save_empty_text))
344+
return null
345+
}
346+
return withContext(Dispatchers.IO) {
347+
when (exportType) {
348+
TEXT -> {
349+
TransactionListDetailsSharable(
350+
transactions,
351+
encodeUrls = false,
352+
).toSharableContent(this@MainActivity)
353+
}
354+
355+
HAR -> {
356+
HarUtils.harStringFromTransactions(
357+
transactions,
358+
getString(R.string.chucker_name),
359+
getString(R.string.chucker_version),
360+
).byteInputStream().source().buffer()
361+
}
362+
}
363+
}
364+
}
365+
366+
private enum class ExportType(val mimeType: String) {
367+
TEXT("text/plain"),
368+
HAR("application/har+json"),
369+
}
370+
251371
companion object {
252372
private const val EXPORT_TXT_FILE_NAME = "transactions.txt"
253373
private const val EXPORT_HAR_FILE_NAME = "transactions.har"

library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadFragment.kt

+23-28
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import android.annotation.SuppressLint
66
import android.app.Activity
77
import android.content.Context
88
import android.graphics.Color
9-
import android.net.Uri
109
import android.os.Bundle
1110
import android.text.SpannableStringBuilder
1211
import android.view.LayoutInflater
@@ -30,14 +29,16 @@ import androidx.lifecycle.withResumed
3029
import com.chuckerteam.chucker.R
3130
import com.chuckerteam.chucker.databinding.ChuckerFragmentTransactionPayloadBinding
3231
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
32+
import com.chuckerteam.chucker.internal.support.FileSaver
3333
import com.chuckerteam.chucker.internal.support.Logger
3434
import com.chuckerteam.chucker.internal.support.calculateLuminance
3535
import com.chuckerteam.chucker.internal.support.combineLatest
3636
import kotlinx.coroutines.Dispatchers
3737
import kotlinx.coroutines.delay
3838
import kotlinx.coroutines.launch
3939
import kotlinx.coroutines.withContext
40-
import java.io.FileOutputStream
40+
import okio.Source
41+
import okio.source
4142
import java.io.IOException
4243
import kotlin.math.abs
4344

@@ -55,7 +56,14 @@ internal class TransactionPayloadFragment :
5556
val applicationContext = requireContext().applicationContext
5657
if (uri != null && transaction != null) {
5758
lifecycleScope.launch {
58-
val result = saveToFile(payloadType, uri, transaction)
59+
val source =
60+
runCatching {
61+
prepareDataToSave(payloadType, transaction)
62+
}.getOrElse {
63+
Logger.error("Failed to save transaction to a file", it)
64+
return@launch
65+
}
66+
val result = FileSaver.saveFile(source, uri, applicationContext.contentResolver)
5967
val toastMessageId =
6068
if (result) {
6169
R.string.chucker_file_saved
@@ -232,6 +240,7 @@ internal class TransactionPayloadFragment :
232240
PayloadType.REQUEST -> {
233241
(false == transaction?.isRequestBodyEncoded) && (0L != (transaction.requestPayloadSize))
234242
}
243+
235244
PayloadType.RESPONSE -> {
236245
(false == transaction?.isResponseBodyEncoded) && (0L != (transaction.responsePayloadSize))
237246
}
@@ -415,35 +424,21 @@ internal class TransactionPayloadFragment :
415424
}
416425
}
417426

418-
private suspend fun saveToFile(
427+
private fun prepareDataToSave(
419428
type: PayloadType,
420-
uri: Uri,
421429
transaction: HttpTransaction,
422-
): Boolean {
423-
return withContext(Dispatchers.IO) {
424-
try {
425-
requireContext().contentResolver.openFileDescriptor(uri, "w")?.use {
426-
FileOutputStream(it.fileDescriptor).use { fos ->
427-
when (type) {
428-
PayloadType.REQUEST -> {
429-
transaction.requestBody?.byteInputStream()?.copyTo(fos)
430-
?: throw IOException(TRANSACTION_EXCEPTION)
431-
}
432-
433-
PayloadType.RESPONSE -> {
434-
transaction.responseBody?.byteInputStream()?.copyTo(fos)
435-
?: throw IOException(TRANSACTION_EXCEPTION)
436-
}
437-
}
438-
}
439-
}
440-
} catch (e: IOException) {
441-
Logger.error("Failed to save transaction to a file", e)
442-
return@withContext false
430+
): Source =
431+
when (type) {
432+
PayloadType.REQUEST -> {
433+
transaction.requestBody?.byteInputStream()?.source()
434+
?: throw IOException(TRANSACTION_EXCEPTION)
435+
}
436+
437+
PayloadType.RESPONSE -> {
438+
transaction.responseBody?.byteInputStream()?.source()
439+
?: throw IOException(TRANSACTION_EXCEPTION)
443440
}
444-
return@withContext true
445441
}
446-
}
447442

448443
private fun isBodyEmpty(
449444
type: PayloadType,

library/src/main/res/menu/chucker_transaction.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
</item>
3939
<item
4040
android:icon="@drawable/chucker_ic_save_white"
41-
android:title="@string/chucker_save"
41+
android:title="@string/chucker_save_body"
4242
android:id="@+id/save_body"
4343
android:visible="false"
4444
app:showAsAction="ifRoom">

library/src/main/res/menu/chucker_transactions_list.xml

+11-1
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,24 @@
1313
android:title="@string/chucker_export"
1414
app:showAsAction="ifRoom">
1515
<menu>
16-
<group>
16+
<group
17+
android:id="@+id/share_group">
1718
<item
1819
android:id="@+id/share_text"
1920
android:title="@string/chucker_share_as_text" />
2021
<item
2122
android:id="@+id/share_har"
2223
android:title="@string/chucker_share_as_har" />
2324
</group>
25+
<group
26+
android:id="@+id/save_group">
27+
<item
28+
android:id="@+id/save_text"
29+
android:title="@string/chucker_save_as_text" />
30+
<item
31+
android:id="@+id/save_har"
32+
android:title="@string/chucker_save_as_har" />
33+
</group>
2434
</menu>
2535
</item>
2636
<item

0 commit comments

Comments
 (0)