This is a test if updates work
This commit is contained in:
Felitendo
2025-05-20 15:23:42 +02:00
parent e65e82c85b
commit ddff25a7c4
465 changed files with 37626 additions and 0 deletions

1
core/network/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View 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")
}
}

View 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()
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,10 @@
package com.looker.network.validation
import java.io.File
interface FileValidator {
@Throws(ValidationException::class)
suspend fun validate(file: File)
}

View 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())

View File

@@ -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)
}
}