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

Add support for HTTP Archive (HAR) #417

Closed
wants to merge 13 commits into from
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Cookie(
@SerializedName("name") val name: String,
@SerializedName("value") val value: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Creator(
@SerializedName("name") val name: String,
@SerializedName("version") val version: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.chuckerteam.chucker.internal.data.har

import androidx.annotation.VisibleForTesting
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.google.gson.annotations.SerializedName
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

internal data class Entry(
@SerializedName("startedDateTime") val startedDateTime: String,
@SerializedName("time") val time: Long,
@SerializedName("request") val request: Request?,
@SerializedName("response") val response: Response?
) {
@VisibleForTesting object DateFormat : ThreadLocal<SimpleDateFormat>() {
override fun initialValue(): SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US)
}

companion object {
fun fromHttpTransaction(transaction: HttpTransaction) = Entry(
startedDateTime = transaction.requestDate.harFormatted(),
time = transaction.tookMs ?: 0,
request = Request.fromHttpTransaction(transaction),
response = Response.fromHttpTransaction(transaction)
)

private fun Long?.harFormatted(): String {
val date = if (this == null) Date() else Date(this)
return DateFormat.get()?.format(date) ?: ""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Har(
@SerializedName("log") val log: Log
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Header(
@SerializedName("name") val name: String,
@SerializedName("value") val value: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Log(
@SerializedName("version") val version: String,
@SerializedName("creator") val creator: Creator,
@SerializedName("entries") val entries: List<Entry>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.chuckerteam.chucker.internal.data.har

import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.google.gson.annotations.SerializedName

internal data class PostData(
@SerializedName("size") val size: Long,
@SerializedName("mimeType") val mimeType: String,
@SerializedName("text") val text: String
) {
companion object {
fun responsePostData(transaction: HttpTransaction): PostData? {
if (transaction.responsePayloadSize == null || !transaction.isResponseBodyPlainText) return null

return PostData(
size = transaction.responsePayloadSize ?: 0,
mimeType = transaction.responseContentType ?: "text",
text = transaction.responseBody ?: ""
)
}

fun requestPostData(transaction: HttpTransaction): PostData? {
if (transaction.requestPayloadSize == null || !transaction.isRequestBodyPlainText) return null
return PostData(
size = transaction.requestPayloadSize ?: 0,
mimeType = transaction.requestContentType ?: "text",
text = transaction.requestBody ?: ""
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName
import okhttp3.HttpUrl

internal data class QueryString(
@SerializedName("name") val name: String,
@SerializedName("value") val value: String
) {
companion object {
fun fromUrl(url: HttpUrl): List<QueryString> {
val querySize = url.querySize()
return (0 until querySize).map { index ->
QueryString(
name = url.queryParameterName(index),
value = url.queryParameterValue(index)
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.chuckerteam.chucker.internal.data.har

import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.google.gson.annotations.SerializedName
import okhttp3.HttpUrl

internal data class Request(
@SerializedName("method") val method: String,
@SerializedName("url") val url: String,
@SerializedName("httpVersion") val httpVersion: String,
@SerializedName("cookies") val cookies: List<Cookie>,
@SerializedName("headers") val headers: List<Header>,
@SerializedName("queryString") val queryString: List<QueryString>,
@SerializedName("postData") val postData: PostData?,
@SerializedName("headersSize") val headersSize: Int,
@SerializedName("bodySize") val bodySize: Long
) {
companion object {
fun fromHttpTransaction(transaction: HttpTransaction): Request? {
if (transaction.requestDate == null) {
return null
}
return Request(
method = transaction.method ?: "",
url = transaction.url ?: "",
httpVersion = transaction.protocol ?: "",
cookies = emptyList(),
headers = transaction.getParsedRequestHeaders()?.map { Header(it.name, it.value) } ?: emptyList(),
queryString = QueryString.fromUrl(HttpUrl.get(transaction.url ?: "")),
postData = PostData.requestPostData(transaction),
headersSize = transaction.requestHeaders?.length ?: 0,
bodySize = transaction.requestPayloadSize ?: 0
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.chuckerteam.chucker.internal.data.har

import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.google.gson.annotations.SerializedName

internal data class Response(
@SerializedName("status") val status: Int,
@SerializedName("statusText") val statusText: String,
@SerializedName("httpVersion") val httpVersion: String,
@SerializedName("cookies") val cookies: List<Cookie>,
@SerializedName("headers") val headers: List<Header>,
@SerializedName("content") val content: PostData?,
@SerializedName("redirectURL") val redirectUrl: String,
@SerializedName("headersSize") val headersSize: Int,
@SerializedName("bodySize") val bodySize: Long,
@SerializedName("timings") val timings: Timings
) {
companion object {
fun fromHttpTransaction(transaction: HttpTransaction): Response? {
if (transaction.responseDate == null) {
return null
}
return Response(
status = transaction.responseCode ?: 0,
statusText = transaction.responseMessage ?: "",
httpVersion = transaction.protocol ?: "",
cookies = emptyList(),
headers = transaction.getParsedResponseHeaders()?.map { Header(it.name, it.value) } ?: emptyList(),
content = PostData.responsePostData(transaction),
redirectUrl = "",
headersSize = transaction.responseHeaders?.length ?: 0,
bodySize = transaction.responsePayloadSize ?: 0,
timings = Timings(0, 0, transaction.tookMs ?: 0)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.chuckerteam.chucker.internal.data.har

import com.google.gson.annotations.SerializedName

internal data class Timings(
@SerializedName("send") val send: Long,
@SerializedName("wait") val wait: Long,
@SerializedName("receive") val receive: Long
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.chuckerteam.chucker.internal.support

import android.app.Activity
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import com.chuckerteam.chucker.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File

internal const val HAR_EXPORT_FILENAME = "transactions.har"
internal const val TXT_EXPORT_FILENAME = "transactions.txt"

internal object FileShareHelper {
suspend fun share(activity: Activity, exportFilename: String, fileContentsFactory: suspend () -> String) {
val cache = activity.cacheDir
if (cache == null) {
println("Failed to obtain a valid cache directory for Chucker file export")
Toast.makeText(activity, R.string.chucker_export_no_file, Toast.LENGTH_SHORT).show()
return
}

val file = createExportFile(cache, exportFilename, fileContentsFactory())

if (file == null) {
println("Failed to create an export file for Chucker")
Toast.makeText(activity, R.string.chucker_export_no_file, Toast.LENGTH_SHORT).show()
return
}

val uri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.com.chuckerteam.chucker.provider",
file
)
shareFile(activity, uri)
}

private suspend fun createExportFile(
cacheDirectory: File,
exportFilename: String,
content: String,
): File? = withContext(Dispatchers.IO) {
FileFactory.create(cacheDirectory, exportFilename)?.apply { writeText(content) }
}

private fun shareFile(activity: Activity, uri: Uri) {
val sendIntent = ShareCompat.IntentBuilder.from(activity)
.setType(activity.contentResolver.getType(uri))
.setChooserTitle(activity.getString(R.string.chucker_share_all_transactions_title))
.setSubject(activity.getString(R.string.chucker_share_all_transactions_subject))
.setStream(uri)
.intent

sendIntent.apply {
clipData = ClipData.newRawUri("transactions", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

activity.startActivity(
Intent.createChooser(
sendIntent,
activity.getString(R.string.chucker_share_all_transactions_title)
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.chuckerteam.chucker.internal.support

import androidx.annotation.VisibleForTesting
import com.chuckerteam.chucker.BuildConfig
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.data.har.Creator
import com.chuckerteam.chucker.internal.data.har.Entry
import com.chuckerteam.chucker.internal.data.har.Har
import com.chuckerteam.chucker.internal.data.har.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

// http://www.softwareishard.com/blog/har-12-spec/
// https://github.com/ahmadnassri/har-spec/blob/master/versions/1.2.md
internal object HarUtils {
suspend fun harStringFromTransactions(
transactions: List<HttpTransaction>
): String = withContext(Dispatchers.Default) {
JsonConverter.nonNullSerializerInstance.toJson(fromHttpTransactions(transactions))
}

@VisibleForTesting fun fromHttpTransactions(transactions: List<HttpTransaction>): Har {
return Har(
log = Log(
version = "1.2",
creator = Creator(
name = BuildConfig.LIBRARY_PACKAGE_NAME,
version = BuildConfig.VERSION_NAME
),
entries = transactions.map(Entry.Companion::fromHttpTransaction).filter { it.response != null }
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,11 @@ internal object JsonConverter {
.setPrettyPrinting()
.create()
}

val nonNullSerializerInstance: Gson by lazy {
GsonBuilder()
.disableHtmlEscaping()
.setPrettyPrinting()
.create()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,8 @@ import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.data.entity.HttpTransactionTuple
import com.chuckerteam.chucker.internal.data.entity.RecordedThrowableTuple
import com.chuckerteam.chucker.internal.data.repository.RepositoryProvider
import com.chuckerteam.chucker.internal.support.FileFactory
import com.chuckerteam.chucker.internal.support.NotificationHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File

internal const val EXPORT_FILENAME = "transactions.txt"

internal class MainViewModel : ViewModel() {

Expand All @@ -44,13 +38,6 @@ internal class MainViewModel : ViewModel() {

suspend fun getAllTransactions(): List<HttpTransaction>? = RepositoryProvider.transaction().getAllTransactions()

suspend fun createExportFile(
content: String,
cacheDirectory: File,
) = withContext(Dispatchers.IO) {
FileFactory.create(cacheDirectory, EXPORT_FILENAME)?.apply { writeText(content) }
}

fun updateItemsFilter(searchQuery: String) {
currentFilter.value = searchQuery
}
Expand Down
Loading