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

File creation fix #427

Merged
merged 14 commits into from
Aug 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package com.chuckerteam.chucker.api

import android.content.Context
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.AndroidCacheFileFactory
import com.chuckerteam.chucker.internal.support.CacheDirectoryProvider
import com.chuckerteam.chucker.internal.support.FileFactory
import com.chuckerteam.chucker.internal.support.IOUtils
import com.chuckerteam.chucker.internal.support.ReportingSink
import com.chuckerteam.chucker.internal.support.TeeSource
import com.chuckerteam.chucker.internal.support.contentType
import com.chuckerteam.chucker.internal.support.hasBody
Expand All @@ -30,17 +31,17 @@ import java.nio.charset.Charset
* @param maxContentLength The maximum length for request and response content
* before their truncation. Warning: setting this value too high may cause unexpected
* results.
* @param fileFactory Provider for [File]s where Chucker will save temporary responses before
* processing them.
* @param cacheDirectoryProvider Provider of [File] where Chucker will save temporary responses
* before processing them.
* @param headersToRedact a [Set] of headers you want to redact. They will be replaced
* with a `**` in the Chucker UI.
*/
class ChuckerInterceptor internal constructor(
private val context: Context,
private val collector: ChuckerCollector = ChuckerCollector(context),
private val maxContentLength: Long = 250000L,
private val fileFactory: FileFactory,
headersToRedact: Set<String> = emptySet()
private val cacheDirectoryProvider: CacheDirectoryProvider,
headersToRedact: Set<String> = emptySet(),
) : Interceptor {

/**
Expand All @@ -61,7 +62,13 @@ class ChuckerInterceptor internal constructor(
collector: ChuckerCollector = ChuckerCollector(context),
maxContentLength: Long = 250000L,
headersToRedact: Set<String> = emptySet()
) : this(context, collector, maxContentLength, AndroidCacheFileFactory(context), headersToRedact)
) : this(
context = context,
collector = collector,
maxContentLength = maxContentLength,
cacheDirectoryProvider = { context.cacheDir },
headersToRedact = headersToRedact,
)

private val io: IOUtils = IOUtils(context)
private val headersToRedact: MutableSet<String> = headersToRedact.toMutableSet()
Expand Down Expand Up @@ -179,18 +186,28 @@ class ChuckerInterceptor internal constructor(
val contentType = responseBody.contentType()
val contentLength = responseBody.contentLength()

val teeSource = TeeSource(
responseBody.source(),
fileFactory.create(),
ChuckerTransactionTeeCallback(response, transaction),
val reportingSink = ReportingSink(
createTempTransactionFile(),
ChuckerTransactionReportingSinkCallback(response, transaction),
maxContentLength
)
val teeSource = TeeSource(responseBody.source(), reportingSink)

return response.newBuilder()
.body(ResponseBody.create(contentType, contentLength, Okio.buffer(teeSource)))
.build()
}

private fun createTempTransactionFile(): File? {
val cache = cacheDirectoryProvider.provide()
return if (cache == null) {
IOException("Failed to obtain a valid cache directory for Chucker transaction file").printStackTrace()
null
} else {
FileFactory.create(cache)
}
}

private fun processResponseBody(
response: Response,
responseBodyBuffer: Buffer,
Expand Down Expand Up @@ -229,33 +246,36 @@ class ChuckerInterceptor internal constructor(
return builder.build()
}

private inner class ChuckerTransactionTeeCallback(
private inner class ChuckerTransactionReportingSinkCallback(
private val response: Response,
private val transaction: HttpTransaction
) : TeeSource.Callback {

override fun onClosed(file: File, totalBytesRead: Long) {
val buffer = try {
readResponseBuffer(file, response.isGzipped)
} catch (e: IOException) {
null
) : ReportingSink.Callback {

override fun onClosed(file: File?, sourceByteCount: Long) {
if (file != null) {
val buffer = readResponseBuffer(file, response.isGzipped)
if (buffer != null) {
processResponseBody(response, buffer, transaction)
}
}
if (buffer != null) processResponseBody(response, buffer, transaction)
transaction.responsePayloadSize = totalBytesRead
transaction.responsePayloadSize = sourceByteCount
collector.onResponseReceived(transaction)
file.delete()
file?.delete()
}

override fun onFailure(file: File, exception: IOException) = exception.printStackTrace()
override fun onFailure(file: File?, exception: IOException) = exception.printStackTrace()

private fun readResponseBuffer(responseBody: File, isGzipped: Boolean): Buffer {
private fun readResponseBuffer(responseBody: File, isGzipped: Boolean) = try {
val bufferedSource = Okio.buffer(Okio.source(responseBody))
val source = if (isGzipped) {
GzipSource(bufferedSource)
} else {
bufferedSource
}
return Buffer().apply { source.use { writeAll(it) } }
Buffer().apply { source.use { writeAll(it) } }
} catch (e: IOException) {
IOException("Response payload couldn't be processed by Chucker", e).printStackTrace()
null
}
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.chuckerteam.chucker.internal.support

import java.io.File

/**
* An interface that returns a reference to a cache directory where temporary files can be
* saved.
*/
internal fun interface CacheDirectoryProvider {
fun provide(): File?
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
package com.chuckerteam.chucker.internal.support

import java.io.File
import java.io.IOException
import java.util.concurrent.atomic.AtomicLong

internal interface FileFactory {
fun create(): File
fun create(filename: String): File
internal object FileFactory {
private val uniqueIdGenerator = AtomicLong()

fun create(directory: File) = create(directory, fileName = "chucker-${uniqueIdGenerator.getAndIncrement()}")

fun create(directory: File, fileName: String): File? = try {
File(directory, fileName).apply {
if (exists() && !delete()) {
throw IOException("Failed to delete file $this")
}
parentFile?.mkdirs()
if (!createNewFile()) {
throw IOException("File $this already exists")
}
}
} catch (e: IOException) {
IOException("An error occurred while creating a Chucker file", e).printStackTrace()
null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.chuckerteam.chucker.internal.support

import okio.Buffer
import okio.Okio
import okio.Sink
import okio.Timeout
import java.io.File
import java.io.IOException

/**
* A sink that reports result of writing to it via [callback].
*
* Takes an input [downstreamFile] and writes bytes from a source into this input. Amount of bytes
* to copy can be limited with [writeByteLimit]. Results are reported back to a client
* when sink is closed or when an exception occurs while creating a downstream sink or while
* writing bytes.
*/
internal class ReportingSink(
private val downstreamFile: File?,
private val callback: Callback,
private val writeByteLimit: Long = Long.MAX_VALUE
) : Sink {
private var totalByteCount = 0L
private var isFailure = false
private var isClosed = false
private var downstream = try {
if (downstreamFile != null) Okio.sink(downstreamFile) else null
} catch (e: IOException) {
callDownstreamFailure(IOException("Failed to use file $downstreamFile by Chucker", e))
null
}

override fun write(source: Buffer, byteCount: Long) {
val previousTotalByteCount = totalByteCount
totalByteCount += byteCount
if (isFailure || previousTotalByteCount >= writeByteLimit) return

val bytesToWrite = if (previousTotalByteCount + byteCount <= writeByteLimit) {
byteCount
} else {
writeByteLimit - previousTotalByteCount
}

if (bytesToWrite == 0L) return

try {
downstream?.write(source, bytesToWrite)
} catch (e: IOException) {
callDownstreamFailure(e)
}
}

override fun flush() {
if (isFailure) return
try {
downstream?.flush()
} catch (e: IOException) {
callDownstreamFailure(e)
}
}

override fun close() {
if (isClosed) return
isClosed = true
safeCloseDownstream()
callback.onClosed(downstreamFile, totalByteCount)
}

override fun timeout(): Timeout = downstream?.timeout() ?: Timeout.NONE

private fun callDownstreamFailure(exception: IOException) {
if (!isFailure) {
isFailure = true
safeCloseDownstream()
callback.onFailure(downstreamFile, exception)
}
}

private fun safeCloseDownstream() = try {
downstream?.close()
} catch (e: IOException) {
callDownstreamFailure(e)
}

interface Callback {
/**
* Called when the sink is closed. All written bytes are copied to the [file].
* This does not mean that the content of the [file] is valid. Only that the client
* is done with the writing process.
*
* [sourceByteCount] is the exact amount of bytes that the were read from upstream even if
* the [file] is corrupted or does not exist. It is not limited by [writeByteLimit].
*/
fun onClosed(file: File?, sourceByteCount: Long)

/**
* Called when an [exception] was thrown while processing data.
* Any written bytes are available in a [file].
*/
fun onFailure(file: File?, exception: IOException)
}
}
Loading