Skip to content
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

Adding collapse/expand JSON payloads feature. #1107

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chuckerteam.chucker.internal.ui.transaction

import android.animation.Animator
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
Expand All @@ -8,8 +9,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.getSpans
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.chuckerteam.chucker.R
import com.chuckerteam.chucker.databinding.ChuckerTransactionItemBodyCollapsableBinding
import com.chuckerteam.chucker.databinding.ChuckerTransactionItemBodyLineBinding
import com.chuckerteam.chucker.databinding.ChuckerTransactionItemHeadersBinding
import com.chuckerteam.chucker.databinding.ChuckerTransactionItemImageBinding
Expand All @@ -18,6 +21,9 @@ import com.chuckerteam.chucker.internal.support.SpanTextUtil
import com.chuckerteam.chucker.internal.support.highlightWithDefinedColors
import com.chuckerteam.chucker.internal.support.highlightWithDefinedColorsSubstring
import com.chuckerteam.chucker.internal.support.indicesOf
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject

/**
* Adapter responsible of showing the content of the Transaction Request/Response body.
Expand Down Expand Up @@ -53,6 +59,12 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter<TransactionPayloadV
TransactionPayloadViewHolder.BodyLineViewHolder(bodyItemBinding)
}

TYPE_BODY_COLLAPSABLE -> {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
TYPE_BODY_COLLAPSABLE -> {
TYPE_BODY_LINE_COLLAPSABLE -> {

Copy link
Member

Choose a reason for hiding this comment

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

Can we rename Collapsable to LineCollapsable everywhere?

val bodyItemBinding =
ChuckerTransactionItemBodyCollapsableBinding.inflate(inflater, parent, false)
TransactionPayloadViewHolder.BodyJsonViewHolder(bodyItemBinding)
}

else -> {
val imageItemBinding = ChuckerTransactionItemImageBinding.inflate(inflater, parent, false)
TransactionPayloadViewHolder.ImageViewHolder(imageItemBinding)
Expand All @@ -66,6 +78,7 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter<TransactionPayloadV
return when (items[position]) {
is TransactionPayloadItem.HeaderItem -> TYPE_HEADERS
is TransactionPayloadItem.BodyLineItem -> TYPE_BODY_LINE
is TransactionPayloadItem.BodyCollapsableItem -> TYPE_BODY_COLLAPSABLE
is TransactionPayloadItem.ImageItem -> TYPE_IMAGE
}
}
Expand Down Expand Up @@ -144,7 +157,8 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter<TransactionPayloadV
companion object {
private const val TYPE_HEADERS = 1
private const val TYPE_BODY_LINE = 2
private const val TYPE_IMAGE = 3
private const val TYPE_BODY_COLLAPSABLE = 3
private const val TYPE_IMAGE = 4
}

/**
Expand Down Expand Up @@ -226,10 +240,182 @@ internal sealed class TransactionPayloadViewHolder(view: View) : RecyclerView.Vi
const val LUMINANCE_THRESHOLD = 0.25
}
}

internal class BodyJsonViewHolder(
Copy link
Member

Choose a reason for hiding this comment

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

Can we be consistent with the naming here?
This should be a BodyLineCollapsibleViewHolder

private val bodyBinding: ChuckerTransactionItemBodyCollapsableBinding
) : TransactionPayloadViewHolder(bodyBinding.root) {

override fun bind(item: TransactionPayloadItem) {
if (item !is TransactionPayloadItem.BodyCollapsableItem) return

if (item.jsonElement == null) {
bodyBinding.clRoot.gone()
return
}

val body = item.jsonElement

when {
body.isJsonPrimitive -> {
bodyBinding.imgExpand.gone()
bodyBinding.rvSectionData.gone()
bodyBinding.txtStartValue.text = body.asString.plus(",")
}

body.isJsonObject -> body.asJsonObject.showObjects()
body.isJsonArray -> body.asJsonArray.showArrayObjects()
else -> Unit
}
}

private fun JsonObject.showProperties() {
val attrList = mutableListOf<TransactionPayloadItem.BodyCollapsableItem>()

for ((key, value) in entrySet()) {
JsonObject().also {
it.add(key, value)
attrList.add(TransactionPayloadItem.BodyCollapsableItem(jsonElement = it))
}
}
Comment on lines +274 to +279
Copy link
Member

Choose a reason for hiding this comment

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

Why are you doing this? Could you explain?


bodyBinding.rvSectionData.show()
bodyBinding.rvSectionData.adapter = TransactionBodyAdapter().also { adapter ->
adapter.setItems(attrList)
}
}

private fun JsonObject.showObjects() {
val obj = this
val keys = obj.keySet()

if (keys.size == 0) return

// { "key" : "value" }
if (keys.size == 1) {
val key = obj.keySet().first()
val value: JsonElement = obj.get(key) ?: return
val keyText = "\"" + key + "\""

bodyBinding.imgExpand.gone()
bodyBinding.txtKey.text = keyText

when {
value.isJsonPrimitive || value.isJsonNull -> {
val text = if (value.isJsonNull) "null" else "\"${value.asString}\""

bodyBinding.txtStartValue.text = text.plus(",")
bodyBinding.txtEndValue.gone()
}

value.isJsonObject -> {
if (value.asJsonObject.isEmpty) {
bodyBinding.rvSectionData.gone()
bodyBinding.txtStartValue.text = "{},"
bodyBinding.txtEndValue.gone()
} else {
bodyBinding.root.setClickForValue(element = value)
}
}

value.isJsonArray -> {
bodyBinding.root.setClickForValue(element = value)
}
}
} else {
// { "key1" : "value1", "key2" : "value2" }
bodyBinding.imgExpand.gone()
bodyBinding.txtKey.gone()
bodyBinding.txtDivider.gone()

bodyBinding.txtStartValue.show()
bodyBinding.txtStartValue.text = "{"
obj.showProperties()
bodyBinding.txtEndValue.show()
bodyBinding.txtEndValue.text = "},"
}
}

private fun JsonArray.showArrayObjects() {
map {
TransactionPayloadItem.BodyCollapsableItem(jsonElement = it.asJsonObject)
}.also { list ->
with(bodyBinding) {
imgExpand.gone()
txtKey.gone()
txtDivider.gone()
txtStartValue.gone()
txtEndValue.gone()
rvSectionData.show()
rvSectionData.adapter = TransactionBodyAdapter().also { adapter ->
adapter.setItems(list)
}
}
}
}

private fun View.setClickForValue(element: JsonElement) = with(bodyBinding) {
var isOpen = false

imgExpand.show()
txtStartValue.text = if (element.isJsonObject) "{...}" else "[...]"
txtEndValue.gone()

setOnClickListener { view ->
isOpen = isOpen.not()

imgExpand.animate()
.rotationBy(if (isOpen) OPEN_ROTATION_VALUE else CLOSE_ROTATION_VALUE)
.setListener(object : Animator.AnimatorListener {
override fun onAnimationStart(p0: Animator) {
view.isClickable = false
}

override fun onAnimationEnd(p0: Animator) {
view.isClickable = true

if (isOpen) {
rvSectionData.show()
txtStartValue.text = if (element.isJsonObject) "{" else "["
txtEndValue.show()
txtEndValue.text = if (element.isJsonObject) "}," else "],"
} else {
rvSectionData.gone()
txtStartValue.text =
if (element.isJsonObject) "{...}," else "[...],"
txtEndValue.gone()
}

rvSectionData.adapter = TransactionBodyAdapter().also { adapter ->
adapter.setItems(
listOf(TransactionPayloadItem.BodyCollapsableItem(jsonElement = element))
)
}
}

@Suppress("EmptyFunctionBlock")
override fun onAnimationCancel(p0: Animator) {
}

@Suppress("EmptyFunctionBlock")
override fun onAnimationRepeat(p0: Animator) {
}
})
}
}

private fun View.show() = apply { isVisible = true }
private fun View.gone() = apply { isVisible = false }
Comment on lines +406 to +407
Copy link
Member

Choose a reason for hiding this comment

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

There is a ktx library inside androidx that was providing those functions already.

view.isGone = true
view.isVisible = true

Can we use it?


internal companion object {
const val OPEN_ROTATION_VALUE = 180f
const val CLOSE_ROTATION_VALUE = -180f
}
}
}

internal sealed class TransactionPayloadItem {
internal class HeaderItem(val headers: Spanned) : TransactionPayloadItem()
internal class BodyLineItem(var line: SpannableStringBuilder) : TransactionPayloadItem()
internal class BodyCollapsableItem(val jsonElement: JsonElement?) : TransactionPayloadItem()
internal class ImageItem(val image: Bitmap, val luminance: Double?) : TransactionPayloadItem()
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import android.text.SpannableStringBuilder
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
Expand All @@ -33,6 +34,9 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.Logger
import com.chuckerteam.chucker.internal.support.calculateLuminance
import com.chuckerteam.chucker.internal.support.combineLatest
import com.google.gson.JsonParser
import com.google.gson.JsonSyntaxException
import com.google.gson.stream.JsonReader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -118,6 +122,8 @@ internal class TransactionPayloadFragment :
payloadBinding.loadingProgress.visibility = View.VISIBLE

val result = processPayload(payloadType, transaction, formatRequestBody)
.getCollapsableOrDefault()

if (result.isEmpty()) {
showEmptyState()
} else {
Expand Down Expand Up @@ -200,6 +206,27 @@ internal class TransactionPayloadFragment :
}
}

if (shouldShowCollapsableIcon(transaction)) {
val collapseIcon = menu.findItem(R.id.collapse).also { item ->
item.setOnMenuItemClickListener {
it.handleCollapseMenu()
}
}
val expandIcon = menu.findItem(R.id.expand).also { item ->
item.setOnMenuItemClickListener {
it.handleCollapseMenu()
}
}

if (viewModel.isUsingCollapsableJson) {
expandIcon.isVisible = true
collapseIcon.isVisible = false
} else {
collapseIcon.isVisible = true
expandIcon.isVisible = false
}
}

if (payloadType == PayloadType.REQUEST) {
viewModel.doesRequestBodyRequireEncoding.observe(
viewLifecycleOwner,
Expand All @@ -222,11 +249,38 @@ internal class TransactionPayloadFragment :
PayloadType.REQUEST -> {
(false == transaction?.isRequestBodyEncoded) && (0L != (transaction.requestPayloadSize))
}

PayloadType.RESPONSE -> {
(false == transaction?.isResponseBodyEncoded) && (0L != (transaction.responsePayloadSize))
}
}

private fun shouldShowCollapsableIcon(transaction: HttpTransaction?): Boolean {
var isJsonContentType = false
var hasContent = false
val jsonContentType = "application/json"

when (payloadType) {
PayloadType.REQUEST -> {
isJsonContentType = transaction?.requestContentType?.contains(jsonContentType) == true
hasContent = (0L != (transaction?.requestPayloadSize))
}

PayloadType.RESPONSE -> {
isJsonContentType = transaction?.responseContentType?.contains(jsonContentType) == true
hasContent = (0L != (transaction?.responsePayloadSize))
}
}

return isJsonContentType && hasContent
}

private fun MenuItem.handleCollapseMenu(): Boolean {
viewModel.toggleCollapsableJson()
activity?.invalidateOptionsMenu()
return true
}

override fun onAttach(context: Context) {
super.onAttach(context)
backgroundSpanColor = ContextCompat.getColor(context, R.color.chucker_background_span_color)
Expand Down Expand Up @@ -461,4 +515,47 @@ internal class TransactionPayloadFragment :
}
return result
}

private fun MutableList<TransactionPayloadItem>.getCollapsableOrDefault(): List<TransactionPayloadItem> {
val default = this

return if (viewModel.isUsingCollapsableJson) {
try {
mapToJsonElements()
} catch (t: JsonSyntaxException) {
Toast.makeText(context, t.message, Toast.LENGTH_LONG).show()
Logger.error(t.message ?: "Error when formatting json")
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather display the "Error when formatting json" in the Toast and dump the stacktrace in logcat instead

viewModel.toggleCollapsableJson()
default
}
} else {
default
}
}

private fun MutableList<TransactionPayloadItem>.mapToJsonElements(): List<TransactionPayloadItem> {
val bodyBuilder = StringBuilder()
val newList = arrayListOf<TransactionPayloadItem>()

forEach { item ->
when (item) {
is TransactionPayloadItem.BodyLineItem -> bodyBuilder.append(item.line)
is TransactionPayloadItem.HeaderItem,
is TransactionPayloadItem.ImageItem -> newList.add(item)

else -> Unit
}
}
Comment on lines +540 to +548
Copy link
Member

Choose a reason for hiding this comment

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

Not a super big fan of this approach where you re-add HeaderItem and ImageItem.
The only thing you need to do is to map BodyLineItem to BodyCollapsibleLineItem if the user clicked. Could you clean this up a bit?

Copy link
Member

Choose a reason for hiding this comment

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

Also can you make this when exhaustive?


val reader = JsonReader(bodyBuilder.toString().reader())
.also { it.isLenient = true }

newList.add(
TransactionPayloadItem.BodyCollapsableItem(
jsonElement = JsonParser.parseReader(reader)
)
)

return newList
}
}
Loading