Skip to content

Commit 7983ad8

Browse files
authored
Migrate HTTP layer from HttpURLConnection to OkHttp (#72)
1 parent 4d04077 commit 7983ad8

8 files changed

Lines changed: 229 additions & 185 deletions

File tree

nubrick/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ dependencies {
7777
implementation("androidx.compose.material3:material3:1.2.1")
7878
implementation("androidx.navigation:navigation-compose:2.7.7")
7979
implementation("androidx.browser:browser:1.8.0")
80+
implementation("com.squareup.okhttp3:okhttp:4.12.0")
8081

8182
implementation("androidx.core:core-ktx:1.12.0")
8283
implementation("androidx.appcompat:appcompat:1.6.1")

nubrick/src/main/kotlin/app/nubrick/nubrick/data/httprequest.kt

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,19 @@
11
package app.nubrick.nubrick.data
22

33
import app.nubrick.nubrick.schema.ApiHttpRequest
4-
import app.nubrick.nubrick.schema.ApiHttpRequestMethod
54
import kotlinx.serialization.json.Json
65
import kotlinx.serialization.json.JsonElement
6+
import okhttp3.OkHttpClient
77

88
internal interface HttpRequestRepository {
99
suspend fun request(req: ApiHttpRequest): Result<JsonElement>
1010
}
1111

12-
internal class HttpRequestRepositoryImpl : HttpRequestRepository {
12+
internal class HttpRequestRepositoryImpl(
13+
private val client: OkHttpClient,
14+
) : HttpRequestRepository {
1315
override suspend fun request(req: ApiHttpRequest): Result<JsonElement> {
14-
val url = req.url ?: return Result.failure(SkipHttpRequestException())
15-
val connection = createHttpUrlConnection(url).getOrElse {
16-
return Result.failure(it)
17-
}
18-
val method = req.method ?: ApiHttpRequestMethod.GET
19-
connection.requestMethod = method.toString()
20-
connection.doInput = true
21-
22-
req.headers?.forEach { header ->
23-
val name = header.name ?: return@forEach
24-
connection.setRequestProperty(name, header.value ?: "")
25-
}
26-
27-
if (method != ApiHttpRequestMethod.GET && method != ApiHttpRequestMethod.TRACE) run {
28-
val body = req.body ?: ""
29-
setBody(connection, body)
30-
}
31-
32-
val response: String = connectAndGetResponse(connection).getOrElse {
16+
val response: String = sendHttpRequest(req, client).getOrElse {
3317
return Result.failure(it)
3418
}
3519
val json = try {
Lines changed: 87 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,50 @@
11
package app.nubrick.nubrick.data
22

3-
import app.nubrick.nubrick.data.user.syncDateFromHttpResponse
3+
import app.nubrick.nubrick.data.user.syncDateFromHttpDateHeader
4+
import app.nubrick.nubrick.schema.ApiHttpRequest
5+
import app.nubrick.nubrick.schema.ApiHttpRequestMethod
46
import kotlinx.coroutines.CoroutineScope
57
import kotlinx.coroutines.Dispatchers
68
import kotlinx.coroutines.launch
9+
import okhttp3.MediaType.Companion.toMediaType
10+
import okhttp3.OkHttpClient
11+
import okhttp3.Request
12+
import okhttp3.RequestBody.Companion.toRequestBody
13+
import okhttp3.ResponseBody
714
import java.io.ByteArrayOutputStream
815
import java.io.IOException
916
import java.io.InputStream
10-
import java.net.HttpURLConnection
1117
import java.net.SocketTimeoutException
12-
import java.net.URL
1318

1419
internal const val CONNECT_TIMEOUT = 10 * 1000
1520
internal const val READ_TIMEOUT = 5 * 1000
21+
private const val HTTP_OK = 200
22+
private const val HTTP_NOT_FOUND = 404
1623
private const val MAX_RETRIES = 2
1724
private val RETRY_DELAYS = longArrayOf(1000, 2000)
1825
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024
1926
private const val MAX_ERROR_BODY_SIZE = 4 * 1024
27+
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
2028

2129
internal class HttpException(val statusCode: Int, body: String?) :
2230
Exception("HTTP $statusCode" + if (body.isNullOrBlank()) "" else ": $body")
2331

2432
internal class NetworkRepository(
2533
private val scope: CoroutineScope,
2634
private val cache: CacheStore,
35+
private val client: OkHttpClient,
2736
) {
2837
fun getWithCache(endpoint: String, syncDateTime: Boolean = false): Result<String> {
2938
val cached = cache.get(endpoint).getOrElse {
30-
val result = getRequest(endpoint, syncDateTime).getOrElse { error ->
39+
val result = getRequest(endpoint, syncDateTime, client).getOrElse { error ->
3140
return Result.failure(error)
3241
}
3342
cache.set(endpoint, result).getOrNull()
3443
return Result.success(result)
3544
}
3645
if (cached.isStale()) {
3746
scope.launch(Dispatchers.IO) {
38-
val result = getRequest(endpoint, syncDateTime).getOrNull() ?: return@launch
47+
val result = getRequest(endpoint, syncDateTime, client).getOrNull() ?: return@launch
3948
cache.set(endpoint, result).getOrNull()
4049
}
4150
}
@@ -60,9 +69,9 @@ private fun readStream(stream: InputStream, maxSize: Int = MAX_RESPONSE_SIZE): S
6069
}
6170
}
6271

63-
private fun readErrorBody(connection: HttpURLConnection): String? {
72+
private fun readErrorBody(body: ResponseBody?): String? {
6473
return try {
65-
connection.errorStream?.let { readStream(it, MAX_ERROR_BODY_SIZE) }
74+
body?.byteStream()?.let { readStream(it, MAX_ERROR_BODY_SIZE) }
6675
} catch (_: Exception) {
6776
null
6877
}
@@ -72,131 +81,103 @@ private fun isRetryable(e: Throwable): Boolean {
7281
return e is SocketTimeoutException || (e is HttpException && e.statusCode >= 500)
7382
}
7483

75-
internal fun getRequest(endpoint: String, syncDateTime: Boolean = false): Result<String> {
84+
internal fun getRequest(
85+
endpoint: String,
86+
syncDateTime: Boolean = false,
87+
client: OkHttpClient
88+
): Result<String> = getRequestWithRetry {
89+
try {
90+
val t0 = System.currentTimeMillis()
91+
val request = Request.Builder()
92+
.url(endpoint)
93+
.get()
94+
.build()
95+
executeRequest(client, request, syncDateTime, t0)
96+
} catch (e: IllegalArgumentException) {
97+
Result.failure(e)
98+
}
99+
}
100+
101+
private fun getRequestWithRetry(
102+
request: () -> Result<String>
103+
): Result<String> {
76104
var lastResult: Result<String> = Result.failure(IOException("No attempts made"))
77105
for (attempt in 0..MAX_RETRIES) {
78106
if (attempt > 0) Thread.sleep(RETRY_DELAYS[attempt - 1])
79-
lastResult = getRequestOnce(endpoint, syncDateTime)
107+
lastResult = request()
80108
if (lastResult.isSuccess) return lastResult
81109
val error = lastResult.exceptionOrNull() ?: break
82110
if (!isRetryable(error)) break
83111
}
84112
return lastResult
85113
}
86114

87-
private fun getRequestOnce(endpoint: String, syncDateTime: Boolean): Result<String> {
88-
var connection: HttpURLConnection? = null
89-
try {
90-
val t0 = System.currentTimeMillis()
91-
val url = URL(endpoint)
92-
connection = url.openConnection() as HttpURLConnection
93-
connection.connectTimeout = CONNECT_TIMEOUT
94-
connection.readTimeout = READ_TIMEOUT
95-
connection.requestMethod = "GET"
96-
connection.doOutput = false
97-
connection.doInput = true
98-
connection.useCaches = true
99-
connection.connect()
100-
val responseCode = connection.responseCode
101-
102-
if (syncDateTime) {
103-
syncDateFromHttpResponse(t0, connection)
104-
}
105-
106-
if (responseCode == HttpURLConnection.HTTP_OK) {
107-
return Result.success(readStream(connection.inputStream))
108-
} else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
109-
return Result.failure(NotFoundException())
110-
} else {
111-
return Result.failure(HttpException(responseCode, readErrorBody(connection)))
112-
}
113-
} catch (e: IOException) {
115+
internal fun postRequest(endpoint: String, data: String, client: OkHttpClient): Result<String> {
116+
val request = try {
117+
Request.Builder()
118+
.url(endpoint)
119+
.post(data.toRequestBody(JSON_MEDIA_TYPE))
120+
.build()
121+
} catch (e: IllegalArgumentException) {
114122
return Result.failure(e)
115-
} finally {
116-
connection?.disconnect()
117123
}
124+
return executeRequest(client, request)
118125
}
119126

120-
internal fun postRequest(endpoint: String, data: String): Result<String> {
121-
var connection: HttpURLConnection? = null
122-
try {
123-
val url = URL(endpoint)
124-
connection = url.openConnection() as HttpURLConnection
125-
connection.connectTimeout = CONNECT_TIMEOUT
126-
connection.readTimeout = READ_TIMEOUT
127-
connection.requestMethod = "POST"
128-
connection.doOutput = true
129-
connection.doInput = true
130-
connection.useCaches = false
131-
connection.setRequestProperty("Content-Type", "application/json")
132-
133-
val bodyData = data.toByteArray()
134-
connection.setRequestProperty("Content-Length", bodyData.size.toString())
135-
connection.outputStream.use { outputStream ->
136-
outputStream.write(bodyData)
137-
outputStream.flush()
138-
}
139-
140-
connection.connect()
127+
internal fun sendHttpRequest(req: ApiHttpRequest, client: OkHttpClient): Result<String> {
128+
val url = req.url ?: return Result.failure(SkipHttpRequestException())
129+
val method = req.method ?: ApiHttpRequestMethod.GET
130+
if (method == ApiHttpRequestMethod.UNKNOWN) {
131+
return Result.failure(IllegalArgumentException("Unsupported HTTP method: UNKNOWN"))
132+
}
133+
val request = try {
134+
val builder = Request.Builder().url(url)
141135

142-
val responseCode = connection.responseCode
143-
if (responseCode == HttpURLConnection.HTTP_OK) {
144-
return Result.success(readStream(connection.inputStream))
145-
} else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
146-
return Result.failure(NotFoundException())
147-
} else {
148-
return Result.failure(HttpException(responseCode, readErrorBody(connection)))
136+
req.headers?.forEach { header ->
137+
val name = header.name ?: return@forEach
138+
builder.header(name, header.value ?: "")
149139
}
150140

151-
} catch (e: IOException) {
152-
return Result.failure(e)
153-
} finally {
154-
connection?.disconnect()
155-
}
156-
}
157-
158-
internal fun createHttpUrlConnection(endpoint: String): Result<HttpURLConnection> {
159-
try {
160-
val parsed = URL(endpoint)
161-
if (parsed.protocol != "https" && parsed.protocol != "http") {
162-
return Result.failure(IOException("Unsupported URL scheme: ${parsed.protocol}"))
141+
val body = if (method != ApiHttpRequestMethod.GET &&
142+
method != ApiHttpRequestMethod.HEAD &&
143+
method != ApiHttpRequestMethod.TRACE
144+
) {
145+
(req.body ?: "").toRequestBody(JSON_MEDIA_TYPE)
146+
} else {
147+
null
163148
}
164-
val connection = parsed.openConnection() as HttpURLConnection
165-
connection.connectTimeout = CONNECT_TIMEOUT
166-
connection.readTimeout = READ_TIMEOUT
167-
return Result.success(connection)
168-
} catch (e: Exception) {
149+
builder.method(method.toString(), body).build()
150+
} catch (e: IllegalArgumentException) {
169151
return Result.failure(e)
170152
}
171-
}
172-
173-
internal fun setBody(connection: HttpURLConnection, body: String) {
174-
connection.doOutput = true
175-
connection.useCaches = false
176-
connection.setRequestProperty("Content-Type", "application/json")
177153

178-
val bodyData = body.toByteArray()
179-
connection.setRequestProperty("Content-Length", bodyData.size.toString())
180-
connection.outputStream.use { outputStream ->
181-
outputStream.write(bodyData)
182-
outputStream.flush()
183-
}
154+
return executeRequest(client, request)
184155
}
185156

186-
internal fun connectAndGetResponse(connection: HttpURLConnection): Result<String> {
157+
private fun executeRequest(
158+
client: OkHttpClient,
159+
request: Request,
160+
syncDateTime: Boolean = false,
161+
t0: Long = System.currentTimeMillis(),
162+
): Result<String> {
187163
try {
188-
connection.connect()
189-
val responseCode = connection.responseCode
190-
if (responseCode == HttpURLConnection.HTTP_OK) {
191-
return Result.success(readStream(connection.inputStream))
192-
} else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
193-
return Result.failure(NotFoundException())
194-
} else {
195-
return Result.failure(HttpException(responseCode, readErrorBody(connection)))
164+
client.newCall(request).execute().use { response ->
165+
if (syncDateTime && response.networkResponse != null) {
166+
syncDateFromHttpDateHeader(t0, System.currentTimeMillis(), response.header("Date"))
167+
}
168+
169+
return when (response.code) {
170+
HTTP_OK -> {
171+
val body = response.body ?: return Result.failure(IOException("Empty response body"))
172+
Result.success(readStream(body.byteStream()))
173+
}
174+
HTTP_NOT_FOUND -> Result.failure(NotFoundException())
175+
else -> Result.failure(HttpException(response.code, readErrorBody(response.body)))
176+
}
196177
}
197178
} catch (e: IOException) {
198179
return Result.failure(e)
199-
} finally {
200-
connection.disconnect()
180+
} catch (e: IllegalArgumentException) {
181+
return Result.failure(e)
201182
}
202183
}

nubrick/src/main/kotlin/app/nubrick/nubrick/data/track.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import kotlinx.coroutines.channels.Channel
1515
import kotlinx.coroutines.isActive
1616
import kotlinx.coroutines.launch
1717
import kotlinx.coroutines.withTimeoutOrNull
18+
import okhttp3.OkHttpClient
1819
import kotlinx.serialization.Serializable
1920
import kotlinx.serialization.encodeToString
2021
import kotlinx.serialization.json.Json
@@ -174,6 +175,7 @@ internal class TrackRepositoryImpl(
174175
private val config: Config,
175176
private val user: NubrickUser,
176177
private val scope: CoroutineScope,
178+
private val client: OkHttpClient,
177179
) : TrackRepository {
178180
private val eventChannel = Channel<TrackEvent>(capacity = 300)
179181
private val maxBatchSize = 50
@@ -239,7 +241,7 @@ internal class TrackRepositoryImpl(
239241
meta = meta,
240242
)
241243
val body = Json.encodeToString(request.encode())
242-
postRequest(SdkConstants.endpoint.track, body).onFailure {
244+
postRequest(SdkConstants.endpoint.track, body, client).onFailure {
243245
var dropped = 0
244246
events.forEach { event ->
245247
if (eventChannel.trySend(event).isFailure) dropped++

nubrick/src/main/kotlin/app/nubrick/nubrick/data/user/user.kt

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import android.os.Build
66
import app.nubrick.nubrick.VERSION
77
import app.nubrick.nubrick.schema.BuiltinUserProperty
88
import app.nubrick.nubrick.schema.UserPropertyType
9-
import java.net.HttpURLConnection
109
import java.text.SimpleDateFormat
1110
import java.time.Instant
1211
import java.time.ZoneId
@@ -41,15 +40,12 @@ internal fun getCurrentDate(): ZonedDateTime {
4140
)
4241
}
4342

44-
internal fun syncDateFromHttpResponse(t0: Long, connection: HttpURLConnection) {
45-
val t1 = System.currentTimeMillis()
46-
47-
val serverDateStr = connection.getHeaderField("Date") ?: return
48-
43+
internal fun syncDateFromHttpDateHeader(t0: Long, t1: Long, serverDateHeader: String?) {
44+
if (serverDateHeader == null) return
4945
val serverTime = try {
5046
val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
5147
formatter.timeZone = TimeZone.getTimeZone("GMT")
52-
formatter.parse(serverDateStr)?.time ?: return
48+
formatter.parse(serverDateHeader)?.time ?: return
5349
} catch (e: Exception) {
5450
return
5551
}

0 commit comments

Comments
 (0)