diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/http/HotwireHttpClient.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/http/HotwireHttpClient.kt index f908a7cb..af695f46 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/http/HotwireHttpClient.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/http/HotwireHttpClient.kt @@ -52,6 +52,7 @@ object HotwireHttpClient { private fun buildNewHttpClient(): OkHttpClient { val builder = OkHttpClient.Builder() + .cookieJar(WebViewCookieJar()) .connectTimeout(10L, TimeUnit.SECONDS) .readTimeout(30L, TimeUnit.SECONDS) .writeTimeout(30L, TimeUnit.SECONDS) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/http/HttpRepository.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/http/HttpRepository.kt index c1310c0c..f80e2688 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/http/HttpRepository.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/http/HttpRepository.kt @@ -1,6 +1,5 @@ package dev.hotwire.core.turbo.http -import android.webkit.CookieManager import dev.hotwire.core.logging.logError import dev.hotwire.core.turbo.util.dispatcherProvider import kotlinx.coroutines.withContext @@ -9,7 +8,6 @@ import okhttp3.Request import okhttp3.Response internal class HttpRepository { - private val cookieManager = CookieManager.getInstance() data class HttpRequestResult( val response: Response, @@ -54,12 +52,6 @@ internal class HttpRepository { } private fun buildRequest(location: String): Request { - val builder = Request.Builder().url(location) - - cookieManager.getCookie(location)?.let { - builder.header("Cookie", it) - } - - return builder.build() + return Request.Builder().url(location).build() } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJar.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJar.kt new file mode 100644 index 00000000..2c0fbaa5 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJar.kt @@ -0,0 +1,19 @@ +package dev.hotwire.core.turbo.http + +import android.webkit.CookieManager +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +internal class WebViewCookieJar : CookieJar { + private val cookieManager = CookieManager.getInstance() + + override fun loadForRequest(url: HttpUrl): List { + val header = cookieManager.getCookie(url.toString()) ?: return emptyList() + return header.split("; ").mapNotNull { Cookie.parse(url, it) } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + cookies.forEach { cookieManager.setCookie(url.toString(), it.toString()) } + } +} diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflineHttpRepository.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflineHttpRepository.kt index df9aa006..a55b6ad6 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflineHttpRepository.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflineHttpRepository.kt @@ -1,6 +1,5 @@ package dev.hotwire.core.turbo.offline -import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import dev.hotwire.core.logging.logError @@ -22,7 +21,6 @@ import java.io.InputStream * Experimental: API may change, not ready for production use. */ internal class OfflineHttpRepository(private val coroutineScope: CoroutineScope) { - private val cookieManager = CookieManager.getInstance() // Limit pre-cache requests to 2 concurrently private val preCacheRequestQueue = Semaphore(2) @@ -116,36 +114,18 @@ internal class OfflineHttpRepository(private val coroutineScope: CoroutineScope) val headers = resourceRequest.requestHeaders val builder = Request.Builder().url(location) - headers.forEach { builder.header(it.key, it.value) } - - getCookie(location)?.let { - builder.header("Cookie", it) - } + headers + .filterKeys { !it.equals("Cookie", ignoreCase = true) } + .forEach { builder.header(it.key, it.value) } return builder.build() } private fun getResponse(request: Request): Response? { - val location = request.url.toString() val call = HotwireHttpClient.instance.newCall(request) return call.execute().let { response -> - if (response.isSuccessful) { - setCookies(location, response) - response - } else { - null - } - } - } - - private fun getCookie(location: String): String? { - return cookieManager.getCookie(location) - } - - private fun setCookies(location: String, response: Response) { - response.headers("Set-Cookie").forEach { - cookieManager.setCookie(location, it) + if (response.isSuccessful) response else null } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflinePreCacheRequest.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflinePreCacheRequest.kt index c1dd928a..c1c2cc84 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflinePreCacheRequest.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/offline/OfflinePreCacheRequest.kt @@ -1,14 +1,12 @@ package dev.hotwire.core.turbo.offline import android.net.Uri -import android.webkit.CookieManager import android.webkit.WebResourceRequest /** * Experimental: API may change, not ready for production use. */ internal class OfflinePreCacheRequest(val url: String, val userAgent: String) : WebResourceRequest { - private val cookieManager = CookieManager.getInstance() override fun getUrl(): Uri { return Uri.parse(url) @@ -24,7 +22,6 @@ internal class OfflinePreCacheRequest(val url: String, val userAgent: String) : override fun getRequestHeaders(): Map { return mapOf( - "Cookie" to cookieManager.getCookie(url), "User-Agent" to userAgent ) } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/BaseRepositoryTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/BaseRepositoryTest.kt index ef4ddd99..94602911 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/BaseRepositoryTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/BaseRepositoryTest.kt @@ -36,6 +36,7 @@ open class BaseRepositoryTest : BaseUnitTest() { override fun teardown() { super.teardown() + HotwireHttpClient.reset() Dispatchers.resetMain() server.shutdown() } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJarTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJarTest.kt new file mode 100644 index 00000000..d47607d2 --- /dev/null +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/http/WebViewCookieJarTest.kt @@ -0,0 +1,143 @@ +package dev.hotwire.core.turbo.http + +import android.os.Build +import android.webkit.CookieManager +import okhttp3.Cookie +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +class WebViewCookieJarTest { + private val cookieJar = WebViewCookieJar() + private val cookieManager = CookieManager.getInstance() + private val server = MockWebServer() + + private val greenUrl = "https://green.example.com/".toHttpUrl() + private val blueUrl = "https://blue.example.com/".toHttpUrl() + + @Before + fun setup() { + cookieManager.removeAllCookies(null) + server.start() + } + + @After + fun teardown() { + server.shutdown() + } + + @Test + fun `the shared http client uses the web view cookie jar`() { + assertThat(HotwireHttpClient.instance.cookieJar).isInstanceOf(WebViewCookieJar::class.java) + } + + @Test + fun `loadForRequest returns cookies stored for the request host`() { + cookieManager.setCookie(greenUrl.toString(), "flavor=oatmeal") + + val cookies = cookieJar.loadForRequest(greenUrl) + + assertThat(cookies).hasSize(1) + assertThat(cookies[0].name).isEqualTo("flavor") + assertThat(cookies[0].value).isEqualTo("oatmeal") + } + + @Test + fun `loadForRequest returns all cookies stored for the request host`() { + cookieManager.setCookie(greenUrl.toString(), "flavor=oatmeal") + cookieManager.setCookie(greenUrl.toString(), "size=large") + + val cookies = cookieJar.loadForRequest(greenUrl) + + assertThat(cookies.map { it.name }).containsExactlyInAnyOrder("flavor", "size") + } + + @Test + fun `loadForRequest returns an empty list when no cookies are stored`() { + val cookies = cookieJar.loadForRequest(greenUrl) + + assertThat(cookies).isEmpty() + } + + @Test + fun `loadForRequest returns only the cookies for the request host`() { + cookieManager.setCookie(greenUrl.toString(), "flavor=oatmeal") + + val cookies = cookieJar.loadForRequest(blueUrl) + + assertThat(cookies).isEmpty() + } + + @Test + fun `saveFromResponse stores cookies for the response host`() { + val cookie = Cookie.parse(greenUrl, "flavor=oatmeal")!! + + cookieJar.saveFromResponse(greenUrl, listOf(cookie)) + + assertThat(cookieManager.getCookie(greenUrl.toString())).contains("flavor=oatmeal") + } + + @Test + fun `saveFromResponse stores cookies only for the response host`() { + val cookie = Cookie.parse(blueUrl, "flavor=oatmeal")!! + + cookieJar.saveFromResponse(blueUrl, listOf(cookie)) + + assertThat(cookieManager.getCookie(greenUrl.toString())).isNull() + } + + @Test + fun `requests include the cookies stored for their host`() { + val url = server.url("/") + cookieManager.setCookie(url.toString(), "flavor=oatmeal") + server.enqueue(MockResponse()) + + client().newCall(Request.Builder().url(url).build()).execute() + + assertThat(server.takeRequest().getHeader("Cookie")).isEqualTo("flavor=oatmeal") + } + + @Test + fun `response cookies are stored for the response host`() { + val url = server.url("/") + server.enqueue(MockResponse().addHeader("Set-Cookie", "flavor=oatmeal")) + + client().newCall(Request.Builder().url(url).build()).execute() + + assertThat(cookieManager.getCookie(url.toString())).contains("flavor=oatmeal") + } + + @Test + fun `cookies are loaded fresh for each request when following redirects`() { + val url = server.url("/") + server.enqueue( + MockResponse() + .setResponseCode(302) + .addHeader("Location", server.url("/next")) + .addHeader("Set-Cookie", "flavor=oatmeal") + ) + server.enqueue(MockResponse()) + + client().newCall(Request.Builder().url(url).build()).execute() + + server.takeRequest() + assertThat(server.takeRequest().getHeader("Cookie")).isEqualTo("flavor=oatmeal") + } + + private fun client(): OkHttpClient { + return OkHttpClient.Builder() + .cookieJar(cookieJar) + .build() + } +}