diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt index e4483fd38..5614f717d 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionPayloadAdapter.kt @@ -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 @@ -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 @@ -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. @@ -53,6 +59,12 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter { + val bodyItemBinding = + ChuckerTransactionItemBodyCollapsableBinding.inflate(inflater, parent, false) + TransactionPayloadViewHolder.BodyJsonViewHolder(bodyItemBinding) + } + else -> { val imageItemBinding = ChuckerTransactionItemImageBinding.inflate(inflater, parent, false) TransactionPayloadViewHolder.ImageViewHolder(imageItemBinding) @@ -66,6 +78,7 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter TYPE_HEADERS is TransactionPayloadItem.BodyLineItem -> TYPE_BODY_LINE + is TransactionPayloadItem.BodyCollapsableItem -> TYPE_BODY_COLLAPSABLE is TransactionPayloadItem.ImageItem -> TYPE_IMAGE } } @@ -144,7 +157,8 @@ internal class TransactionBodyAdapter : RecyclerView.Adapter { + 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() + + for ((key, value) in entrySet()) { + JsonObject().also { + it.add(key, value) + attrList.add(TransactionPayloadItem.BodyCollapsableItem(jsonElement = it)) + } + } + + 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 } + + 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() } 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 ae9b9e3cc..265330132 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 @@ -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 @@ -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 @@ -118,6 +122,8 @@ internal class TransactionPayloadFragment : payloadBinding.loadingProgress.visibility = View.VISIBLE val result = processPayload(payloadType, transaction, formatRequestBody) + .getCollapsableOrDefault() + if (result.isEmpty()) { showEmptyState() } else { @@ -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, @@ -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) @@ -461,4 +515,47 @@ internal class TransactionPayloadFragment : } return result } + + private fun MutableList.getCollapsableOrDefault(): List { + 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") + viewModel.toggleCollapsableJson() + default + } + } else { + default + } + } + + private fun MutableList.mapToJsonElements(): List { + val bodyBuilder = StringBuilder() + val newList = arrayListOf() + + forEach { item -> + when (item) { + is TransactionPayloadItem.BodyLineItem -> bodyBuilder.append(item.line) + is TransactionPayloadItem.HeaderItem, + is TransactionPayloadItem.ImageItem -> newList.add(item) + + else -> Unit + } + } + + val reader = JsonReader(bodyBuilder.toString().reader()) + .also { it.isLenient = true } + + newList.add( + TransactionPayloadItem.BodyCollapsableItem( + jsonElement = JsonParser.parseReader(reader) + ) + ) + + return newList + } } diff --git a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt index 5f22a7a2f..2c81424dc 100644 --- a/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt +++ b/library/src/main/kotlin/com/chuckerteam/chucker/internal/ui/transaction/TransactionViewModel.kt @@ -15,6 +15,10 @@ internal class TransactionViewModel(transactionId: Long) : ViewModel() { val encodeUrl: LiveData = mutableEncodeUrl + private var _useJsonCollapsable: Boolean = false + val isUsingCollapsableJson: Boolean + get() = _useJsonCollapsable + val transactionTitle: LiveData = RepositoryProvider.transaction() .getTransaction(transactionId) .combineLatest(encodeUrl) { transaction, encodeUrl -> @@ -49,6 +53,11 @@ internal class TransactionViewModel(transactionId: Long) : ViewModel() { fun encodeUrl(encode: Boolean) { mutableEncodeUrl.value = encode } + + fun toggleCollapsableJson() { + _useJsonCollapsable = !_useJsonCollapsable + mutableEncodeUrl.value = encodeUrl.value // Just to fire observer again + } } internal class TransactionViewModelFactory( diff --git a/library/src/main/res/drawable/chucker_ic_collapse_all.xml b/library/src/main/res/drawable/chucker_ic_collapse_all.xml new file mode 100644 index 000000000..ede475010 --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_collapse_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/library/src/main/res/drawable/chucker_ic_expand_all.xml b/library/src/main/res/drawable/chucker_ic_expand_all.xml new file mode 100644 index 000000000..e70dec124 --- /dev/null +++ b/library/src/main/res/drawable/chucker_ic_expand_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/library/src/main/res/layout/chucker_transaction_item_body_collapsable.xml b/library/src/main/res/layout/chucker_transaction_item_body_collapsable.xml new file mode 100644 index 000000000..86014f4df --- /dev/null +++ b/library/src/main/res/layout/chucker_transaction_item_body_collapsable.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + diff --git a/library/src/main/res/menu/chucker_transaction.xml b/library/src/main/res/menu/chucker_transaction.xml index 422e5137c..13b501bdb 100644 --- a/library/src/main/res/menu/chucker_transaction.xml +++ b/library/src/main/res/menu/chucker_transaction.xml @@ -43,4 +43,20 @@ android:visible="false" app:showAsAction="ifRoom"> + + + + + + diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml index ad7313078..4643b6092 100644 --- a/library/src/main/res/values/strings.xml +++ b/library/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ Share as file Share as .har file Save body to file + Expand all + Collapse all Failed to open file chooser File saved successfully! Failed to save file @@ -64,4 +66,5 @@ <Unable to discover GraphQL operation name> Buttons to scroll the search items Search Results: + :