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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ buildscript {
coroutineVersion = '1.3.9'

// Google libraries
coreLibraryDesugaringVersion = '1.0.9'
appCompatVersion = '1.2.0'
constraintLayoutVersion = '2.0.0'
materialComponentsVersion = '1.2.0'
Expand Down
6 changes: 6 additions & 0 deletions library/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ android {

compileOptions {
kotlinOptions.freeCompilerArgs += ['-module-name', "com.github.ChuckerTeam.Chucker.library"]
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

defaultConfig {
minSdkVersion rootProject.minSdkVersion
versionName VERSION_NAME
versionCode VERSION_CODE.toInteger()
consumerProguardFiles 'proguard-rules.pro'
multiDexEnabled true
}

kotlinOptions {
Expand Down Expand Up @@ -62,6 +66,8 @@ dependencies {
implementation "com.google.code.gson:gson:$gsonVersion"
api "com.squareup.okhttp3:okhttp:$okhttp3Version"

coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$coreLibraryDesugaringVersion"

testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
testImplementation "junit:junit:$vintageJunitVersion"
Expand Down
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,35 @@
package com.chuckerteam.chucker.internal.data.har

import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.google.gson.annotations.SerializedName
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
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?
) {
companion object {
val DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US)

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) {
Instant.now().atZone(ZoneId.systemDefault())
} else {
Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())
}
return DATE_FORMAT.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
Expand Up @@ -4,7 +4,8 @@ import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicLong

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

internal class AndroidCacheFileFactory(
context: Context
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.chuckerteam.chucker.internal.support

import android.app.Activity
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.net.Uri
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 object FileShareHelper {
suspend fun share(activity: Activity, exportFilename: String, fileContentsFactory: suspend () -> String) {
val file = createExportFile(activity.applicationContext, exportFilename, fileContentsFactory())
val uri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.com.chuckerteam.chucker.provider",
file
)
shareFile(activity, uri)
}

private suspend fun createExportFile(
context: Context,
exportFilename: String,
content: String
): File = withContext(Dispatchers.IO) {
val file = AndroidCacheFileFactory(context).create(exportFilename)
file.writeText(content)
return@withContext file
}

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,13 +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.EXPORT_FILENAME
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 class MainViewModel : ViewModel() {

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

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

suspend fun createExportFile(content: String, fileFactory: FileFactory): File = withContext(Dispatchers.IO) {
val file = fileFactory.create(EXPORT_FILENAME)
file.writeText(content)
return@withContext file
}

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