v0.6.4
This commit is contained in:
1
core/network/.gitignore
vendored
Normal file
1
core/network/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
26
core/network/build.gradle.kts
Normal file
26
core/network/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
alias(libs.plugins.looker.jvm.library)
|
||||
alias(libs.plugins.looker.hilt)
|
||||
alias(libs.plugins.looker.lint)
|
||||
alias(libs.plugins.ktor)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
modules(Modules.coreDI)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.core)
|
||||
implementation(libs.ktor.okhttp)
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testImplementation(libs.ktor.mock)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(kotlin("test"))
|
||||
testRuntimeOnly(libs.junit.platform)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
34
core/network/src/main/kotlin/com/looker/network/DataSize.kt
Normal file
34
core/network/src/main/kotlin/com/looker/network/DataSize.kt
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.looker.network
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@JvmInline
|
||||
value class DataSize(val value: Long) {
|
||||
|
||||
companion object {
|
||||
private const val BYTE_SIZE = 1024L
|
||||
private val sizeFormats = listOf("%.0f B", "%.0f kB", "%.1f MB", "%.2f GB")
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val (size, index) = generateSequence(Pair(value.toFloat(), 0)) { (size, index) ->
|
||||
if (size >= BYTE_SIZE) {
|
||||
Pair(size / BYTE_SIZE, index + 1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.take(sizeFormats.size).last()
|
||||
return sizeFormats[index].format(Locale.US, size)
|
||||
}
|
||||
}
|
||||
|
||||
val File.size: Long?
|
||||
get() = if (exists()) length().takeIf { it > 0L } else null
|
||||
|
||||
infix fun DataSize.percentBy(denominator: DataSize?): Int = value percentBy denominator?.value
|
||||
|
||||
infix fun Long.percentBy(denominator: Long?): Int {
|
||||
if (denominator == null || denominator < 1) return -1
|
||||
return (this * 100 / denominator).toInt()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.looker.network
|
||||
|
||||
import com.looker.network.header.HeadersBuilder
|
||||
import com.looker.network.validation.FileValidator
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
|
||||
interface Downloader {
|
||||
|
||||
fun setProxy(proxy: Proxy)
|
||||
|
||||
suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit = {}
|
||||
): NetworkResponse
|
||||
|
||||
suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator? = null,
|
||||
headers: HeadersBuilder.() -> Unit = {},
|
||||
block: ProgressListener? = null
|
||||
): NetworkResponse
|
||||
|
||||
companion object {
|
||||
internal const val CONNECTION_TIMEOUT = 30_000L
|
||||
internal const val SOCKET_TIMEOUT = 15_000L
|
||||
|
||||
internal const val USER_AGENT = "Droid-ify, v0.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
typealias ProgressListener = suspend (bytesReceived: DataSize, contentLength: DataSize) -> Unit
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.looker.network
|
||||
|
||||
import com.looker.network.Downloader.Companion.CONNECTION_TIMEOUT
|
||||
import com.looker.network.Downloader.Companion.SOCKET_TIMEOUT
|
||||
import com.looker.network.Downloader.Companion.USER_AGENT
|
||||
import com.looker.network.header.HeadersBuilder
|
||||
import com.looker.network.header.KtorHeadersBuilder
|
||||
import com.looker.network.validation.FileValidator
|
||||
import com.looker.network.validation.ValidationException
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.UserAgent
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.request.request
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.etag
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.http.lastModified
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import io.ktor.utils.io.jvm.javaio.copyTo
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.Proxy
|
||||
|
||||
internal class KtorDownloader(
|
||||
httpClientEngine: HttpClientEngine,
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
) : Downloader {
|
||||
|
||||
private var client = client(httpClientEngine)
|
||||
set(newClient) {
|
||||
field.close()
|
||||
field = newClient
|
||||
}
|
||||
|
||||
override fun setProxy(proxy: Proxy) {
|
||||
client = client(OkHttp.create { this.proxy = proxy })
|
||||
}
|
||||
|
||||
override suspend fun headCall(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit
|
||||
): NetworkResponse {
|
||||
val headRequest = createRequest(
|
||||
url = url,
|
||||
headers = headers
|
||||
)
|
||||
return client.head(headRequest).asNetworkResponse()
|
||||
}
|
||||
|
||||
override suspend fun downloadToFile(
|
||||
url: String,
|
||||
target: File,
|
||||
validator: FileValidator?,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
block: ProgressListener?
|
||||
): NetworkResponse = withContext(dispatcher) {
|
||||
try {
|
||||
val request = createRequest(
|
||||
url = url,
|
||||
headers = {
|
||||
inRange(target.size)
|
||||
headers()
|
||||
},
|
||||
fileSize = target.size,
|
||||
block = block
|
||||
)
|
||||
client.prepareGet(request).execute { response ->
|
||||
val networkResponse = response.asNetworkResponse()
|
||||
if (networkResponse !is NetworkResponse.Success) {
|
||||
return@execute networkResponse
|
||||
}
|
||||
response.bodyAsChannel().copyTo(target.outputStream())
|
||||
validator?.validate(target)
|
||||
networkResponse
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
NetworkResponse.Error.SocketTimeout(e)
|
||||
} catch (e: ConnectTimeoutException) {
|
||||
NetworkResponse.Error.ConnectionTimeout(e)
|
||||
} catch (e: IOException) {
|
||||
NetworkResponse.Error.IO(e)
|
||||
} catch (e: ValidationException) {
|
||||
target.delete()
|
||||
NetworkResponse.Error.Validation(e)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
NetworkResponse.Error.Unknown(e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
fun client(
|
||||
engine: HttpClientEngine = OkHttp.create()
|
||||
): HttpClient {
|
||||
return HttpClient(engine) {
|
||||
userAgentConfig()
|
||||
timeoutConfig()
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpClientConfig<*>.userAgentConfig() = install(UserAgent) {
|
||||
agent = USER_AGENT
|
||||
}
|
||||
|
||||
fun HttpClientConfig<*>.timeoutConfig() = install(HttpTimeout) {
|
||||
connectTimeoutMillis = CONNECTION_TIMEOUT
|
||||
socketTimeoutMillis = SOCKET_TIMEOUT
|
||||
}
|
||||
|
||||
fun createRequest(
|
||||
url: String,
|
||||
headers: HeadersBuilder.() -> Unit,
|
||||
fileSize: Long? = null,
|
||||
block: ProgressListener? = null
|
||||
) = request {
|
||||
url(url)
|
||||
headers {
|
||||
KtorHeadersBuilder(this).headers()
|
||||
}
|
||||
onDownload { read, total ->
|
||||
if (block != null) {
|
||||
block(
|
||||
DataSize(read + (fileSize ?: 0L)),
|
||||
DataSize((total ?: 0L) + (fileSize ?: 0L))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpResponse.asNetworkResponse(): NetworkResponse =
|
||||
if (status.isSuccess() || status == HttpStatusCode.NotModified) {
|
||||
NetworkResponse.Success(status.value, lastModified(), etag())
|
||||
} else {
|
||||
NetworkResponse.Error.Http(status.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.looker.network
|
||||
|
||||
import com.looker.network.validation.ValidationException
|
||||
import java.util.Date
|
||||
|
||||
sealed interface NetworkResponse {
|
||||
|
||||
sealed interface Error : NetworkResponse {
|
||||
|
||||
data class ConnectionTimeout(val exception: Exception) : Error
|
||||
|
||||
data class SocketTimeout(val exception: Exception) : Error
|
||||
|
||||
data class IO(val exception: Exception) : Error
|
||||
|
||||
data class Validation(val exception: ValidationException) : Error
|
||||
|
||||
data class Unknown(val exception: Exception) : Error
|
||||
|
||||
data class Http(val statusCode: Int) : Error
|
||||
}
|
||||
|
||||
data class Success(
|
||||
val statusCode: Int,
|
||||
val lastModified: Date?,
|
||||
val etag: String?
|
||||
) : NetworkResponse
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.looker.network.di
|
||||
|
||||
import com.looker.core.di.IoDispatcher
|
||||
import com.looker.network.Downloader
|
||||
import com.looker.network.KtorDownloader
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideDownloader(
|
||||
@IoDispatcher
|
||||
dispatcher: CoroutineDispatcher
|
||||
): Downloader = KtorDownloader(
|
||||
httpClientEngine = OkHttp.create(),
|
||||
dispatcher = dispatcher,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.looker.network.header
|
||||
|
||||
import java.util.Date
|
||||
|
||||
interface HeadersBuilder {
|
||||
|
||||
infix fun String.headsWith(value: Any?)
|
||||
|
||||
fun etag(etagString: String)
|
||||
|
||||
fun ifModifiedSince(date: Date)
|
||||
|
||||
fun ifModifiedSince(date: String)
|
||||
|
||||
fun authentication(username: String, password: String)
|
||||
|
||||
fun authentication(base64: String)
|
||||
|
||||
fun inRange(start: Number?, end: Number? = null)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.looker.network.header
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.util.encodeBase64
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal class KtorHeadersBuilder(
|
||||
private val builder: io.ktor.http.HeadersBuilder
|
||||
) : HeadersBuilder {
|
||||
|
||||
override fun String.headsWith(value: Any?) {
|
||||
if (value == null) return
|
||||
with(builder) {
|
||||
append(this@headsWith, value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun etag(etagString: String) {
|
||||
HttpHeaders.ETag headsWith etagString
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: Date) {
|
||||
HttpHeaders.IfModifiedSince headsWith date.toFormattedString()
|
||||
}
|
||||
|
||||
override fun ifModifiedSince(date: String) {
|
||||
HttpHeaders.IfModifiedSince headsWith date
|
||||
}
|
||||
|
||||
override fun authentication(username: String, password: String) {
|
||||
HttpHeaders.Authorization headsWith "Basic ${"$username:$password".encodeBase64()}"
|
||||
}
|
||||
|
||||
override fun authentication(base64: String) {
|
||||
HttpHeaders.Authorization headsWith base64
|
||||
}
|
||||
|
||||
override fun inRange(start: Number?, end: Number?) {
|
||||
if (start == null) return
|
||||
val valueString = if (end != null) "bytes=$start-$end" else "bytes=$start-"
|
||||
HttpHeaders.Range headsWith valueString
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val HTTP_DATE_FORMAT: SimpleDateFormat
|
||||
get() = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT")
|
||||
}
|
||||
|
||||
fun Date.toFormattedString(): String = HTTP_DATE_FORMAT.format(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.looker.network.validation
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface FileValidator {
|
||||
|
||||
@Throws(ValidationException::class)
|
||||
suspend fun validate(file: File)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.looker.network.validation
|
||||
|
||||
class ValidationException(override val message: String) : Exception(message)
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun invalid(message: Any): Nothing = throw ValidationException(message.toString())
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.looker.network
|
||||
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.engine.mock.respondError
|
||||
import io.ktor.client.engine.mock.respondOk
|
||||
import io.ktor.client.plugins.ConnectTimeoutException
|
||||
import io.ktor.client.plugins.SocketTimeoutException
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class KtorDownloaderTest {
|
||||
|
||||
private val engine = MockEngine { request ->
|
||||
when (request.url.host) {
|
||||
"success.com" -> respondOk("success")
|
||||
"notfound.com" -> respondError(HttpStatusCode.NotFound)
|
||||
"connection.com" -> throw ConnectTimeoutException(request)
|
||||
"socket.com" -> throw SocketTimeoutException(request)
|
||||
"notmodified.com" -> respond("", HttpStatusCode.NotModified)
|
||||
"authenticate.com" -> respondError(HttpStatusCode.Unauthorized)
|
||||
|
||||
else -> TODO("Not implemented for: ${request.url.host}")
|
||||
}
|
||||
}
|
||||
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
private val downloader = KtorDownloader(engine, dispatcher)
|
||||
|
||||
@Test
|
||||
fun `head call success`() = runTest(dispatcher) {
|
||||
val response = downloader.headCall("https://success.com")
|
||||
assertIs<NetworkResponse.Success>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `head call if path not found`() = runTest(dispatcher) {
|
||||
val response = downloader.headCall("https://notfound.com")
|
||||
assertIs<NetworkResponse.Error.Http>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to file success`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
val response = downloader.downloadToFile("https://success.com", target = file)
|
||||
assertIs<NetworkResponse.Success>(response)
|
||||
assertEquals("success", file.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to read-only file`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
file.setReadOnly()
|
||||
val response = downloader.downloadToFile("https://success.com", target = file)
|
||||
assertIs<NetworkResponse.Error.IO>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to file with slow connection`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
val response = downloader.downloadToFile("https://connection.com", target = file)
|
||||
assertIs<NetworkResponse.Error.ConnectionTimeout>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to file with socket error`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
val response = downloader.downloadToFile("https://socket.com", target = file)
|
||||
assertIs<NetworkResponse.Error.SocketTimeout>(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to file if not modifier`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
val response = downloader.downloadToFile(
|
||||
"https://notmodified.com",
|
||||
target = file,
|
||||
headers = {
|
||||
ifModifiedSince("")
|
||||
}
|
||||
)
|
||||
assertIs<NetworkResponse.Success>(response)
|
||||
assertEquals("", file.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save text to file with wrong authentication`() = runTest(dispatcher) {
|
||||
val file = File.createTempFile("test", "success")
|
||||
val response = downloader.downloadToFile(
|
||||
"https://authenticate.com",
|
||||
target = file,
|
||||
headers = {
|
||||
authentication("iamlooker", "sneakypeaky")
|
||||
}
|
||||
)
|
||||
assertIs<NetworkResponse.Error.Http>(response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user