Skip to content

Commit fb2d2bc

Browse files
authored
Omit Cookie downloads header when empty rather than empty string (#6564)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1211033344698349?focus=true ### Description From #6516, investigated and found that empty `Cookie` HTTP headers are tripping up Calibre's web server. - Empty `Cookie` headers are legal but there are some sparse reports of web servers failing to parse them - Rather than an empty `Cookie` header, we can just omit sending it at all That fixes the problem for Calibre (and i'm sure a few other services). Fixes #6516. Feature flagged so we can revert to the existing behaviour if we notice problems because of this change. ### Steps to test this PR - [x] Test some downloads still succeed as normal (e.g., https://file-examples.com/) - [ ] [Optional]: - [ ] [install Calibre](https://calibre-ebook.com/download) - [ ] Tap the `Connect/share` button - [ ] `Start content server` - [ ] Browse to the URL from the Android device running this fixed branch, and download the sample book Co-authored-by: Craig Russell <[email protected]>
1 parent 226a5d2 commit fb2d2bc

File tree

5 files changed

+121
-5
lines changed

5 files changed

+121
-5
lines changed

downloads/downloads-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ dependencies {
8686
testImplementation 'app.cash.turbine:turbine:_'
8787
testImplementation Testing.robolectric
8888
testImplementation project(path: ':common-test')
89+
testImplementation project(':feature-toggles-test')
8990

9091
coreLibraryDesugaring Android.tools.desugarJdkLibs
9192
}

downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/DownloadFileService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ interface DownloadFileService {
3535
@Streaming
3636
@GET
3737
fun downloadFile(
38-
@Header("Cookie") cookie: String,
38+
@Header("Cookie") cookie: String?,
3939
@Url urlString: String,
4040
): Call<ResponseBody>
4141
}

downloads/downloads-impl/src/main/java/com/duckduckgo/downloads/impl/UrlFileDownloader.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.duckduckgo.downloads.api.DownloadFailReason.ConnectionRefused
2222
import com.duckduckgo.downloads.api.DownloadFailReason.Other
2323
import com.duckduckgo.downloads.api.FileDownloader
2424
import com.duckduckgo.downloads.api.model.DownloadItem
25+
import com.duckduckgo.downloads.impl.feature.FileDownloadFeature
2526
import com.duckduckgo.downloads.store.DownloadStatus.STARTED
2627
import java.io.File
2728
import javax.inject.Inject
@@ -38,6 +39,7 @@ class UrlFileDownloader @Inject constructor(
3839
private val downloadFileService: DownloadFileService,
3940
private val urlFileDownloadCallManager: UrlFileDownloadCallManager,
4041
private val cookieManagerWrapper: CookieManagerWrapper,
42+
private val fileDownloadFeature: FileDownloadFeature,
4143
) {
4244

4345
@WorkerThread
@@ -50,7 +52,7 @@ class UrlFileDownloader @Inject constructor(
5052
val directory = pendingFileDownload.directory
5153
val call = downloadFileService.downloadFile(
5254
urlString = url,
53-
cookie = cookieManagerWrapper.getCookie(url).orEmpty(),
55+
cookie = cookieManagerWrapper.getCookie(url).handleNull(),
5456
)
5557
val downloadId = Random.nextLong()
5658
urlFileDownloadCallManager.add(downloadId, call)
@@ -157,6 +159,17 @@ class UrlFileDownloader @Inject constructor(
157159
return file
158160
}
159161

162+
private fun String?.handleNull(): String? {
163+
if (this != null) return this
164+
165+
// if there are no cookies, we omit sending the cookie header when ff is enabled
166+
return if (fileDownloadFeature.omitEmptyCookieHeader().isEnabled()) {
167+
null
168+
} else {
169+
"" // legacy behavior, we send an empty cookie header
170+
}
171+
}
172+
160173
/**
161174
* This method calculates fake progress that will be used in cases where the file content length is not known.
162175
* The fake progress curve follows 1-Math.exp(-step)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.downloads.impl.feature
18+
19+
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.feature.toggles.api.Toggle
22+
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
23+
24+
/**
25+
* This is the class that represents file download feature flags and kill switches
26+
*/
27+
@ContributesRemoteFeature(
28+
scope = AppScope::class,
29+
featureName = "androidFileDownloadFeature",
30+
)
31+
interface FileDownloadFeature {
32+
33+
// self() is required, but it is not currently used in the codebase
34+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
35+
fun self(): Toggle
36+
37+
// This kill switch can be used to revert to the old behaviour of sending an empty Cookie header instead of omitting it
38+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
39+
fun omitEmptyCookieHeader(): Toggle
40+
}

downloads/downloads-impl/src/test/java/com/duckduckgo/downloads/impl/UrlFileDownloaderTest.kt

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616

1717
package com.duckduckgo.downloads.impl
1818

19+
import android.annotation.SuppressLint
1920
import com.duckduckgo.common.test.CoroutineTestRule
2021
import com.duckduckgo.downloads.api.DownloadFailReason
2122
import com.duckduckgo.downloads.api.FileDownloader
23+
import com.duckduckgo.downloads.impl.feature.FileDownloadFeature
24+
import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory
25+
import com.duckduckgo.feature.toggles.api.Toggle
2226
import java.io.File
2327
import kotlinx.coroutines.test.runTest
2428
import okhttp3.ResponseBody
@@ -31,6 +35,7 @@ import org.mockito.kotlin.*
3135
import retrofit2.Call
3236
import retrofit2.Response
3337

38+
@SuppressLint("DenyListedApi")
3439
class UrlFileDownloaderTest {
3540
@get:Rule
3641
@Suppress("unused")
@@ -42,15 +47,18 @@ class UrlFileDownloaderTest {
4247

4348
private lateinit var urlFileDownloader: UrlFileDownloader
4449

50+
private val fileDownloadFeature = FakeFeatureToggleFactory.create(FileDownloadFeature::class.java)
51+
4552
@Before
4653
fun setup() {
4754
realFileDownloadManager = RealUrlFileDownloadCallManager()
48-
whenever(downloadFileService.downloadFile(anyString(), anyString())).thenReturn(call)
55+
whenever(downloadFileService.downloadFile(anyOrNull(), anyString())).thenReturn(call)
4956

5057
urlFileDownloader = UrlFileDownloader(
5158
downloadFileService,
5259
realFileDownloadManager,
5360
FakeCookieManagerWrapper(),
61+
fileDownloadFeature = fileDownloadFeature,
5462
)
5563
}
5664

@@ -171,6 +179,60 @@ class UrlFileDownloaderTest {
171179
verify(downloadCallback, never()).onError(eq(pendingFileDownload.url), any(), eq(DownloadFailReason.ConnectionRefused))
172180
}
173181

182+
@Test
183+
fun whenFeatureFlagEnabledThenCookieHeaderOmittedForEmptyCookies() = runTest {
184+
fileDownloadFeature.omitEmptyCookieHeader().setRawStoredState(Toggle.State(enable = true))
185+
186+
val pendingFileDownload = buildPendingDownload("https://example.com/file.txt")
187+
urlFileDownloader.downloadFile(pendingFileDownload, "file.txt", mock<DownloadCallback>())
188+
189+
verify(downloadFileService).downloadFile(cookie = eq(null), urlString = any())
190+
}
191+
192+
@Test
193+
fun whenFeatureFlagDisabledThenCookieHeaderEmptyStringForEmptyCookies() = runTest {
194+
fileDownloadFeature.omitEmptyCookieHeader().setRawStoredState(Toggle.State(enable = false))
195+
196+
val pendingFileDownload = buildPendingDownload("https://example.com/file.txt")
197+
urlFileDownloader.downloadFile(pendingFileDownload, "file.txt", mock<DownloadCallback>())
198+
199+
verify(downloadFileService).downloadFile(cookie = eq(""), urlString = any())
200+
}
201+
202+
@Test
203+
fun whenFeatureFlagEnabledAndCookieNotNullThenCookieHeaderIncluded() = runTest {
204+
fileDownloadFeature.omitEmptyCookieHeader().setRawStoredState(Toggle.State(enable = true))
205+
206+
val urlFileDownloader = UrlFileDownloader(
207+
downloadFileService,
208+
realFileDownloadManager,
209+
FakeCookieManagerWrapper("session=abc123; token=xyz"),
210+
fileDownloadFeature = fileDownloadFeature,
211+
)
212+
213+
val pendingFileDownload = buildPendingDownload("https://example.com/file.txt")
214+
urlFileDownloader.downloadFile(pendingFileDownload, "file.txt", mock<DownloadCallback>())
215+
216+
verify(downloadFileService).downloadFile(cookie = eq("session=abc123; token=xyz"), urlString = any())
217+
}
218+
219+
@Test
220+
fun whenFeatureFlagDisabledAndCookieNotNullThenCookieHeaderIncluded() = runTest {
221+
fileDownloadFeature.omitEmptyCookieHeader().setRawStoredState(Toggle.State(enable = false))
222+
223+
val urlFileDownloader = UrlFileDownloader(
224+
downloadFileService,
225+
realFileDownloadManager,
226+
FakeCookieManagerWrapper("session=abc123; token=xyz"),
227+
fileDownloadFeature = fileDownloadFeature,
228+
)
229+
230+
val pendingFileDownload = buildPendingDownload("https://example.com/file.txt")
231+
urlFileDownloader.downloadFile(pendingFileDownload, "file.txt", mock<DownloadCallback>())
232+
233+
verify(downloadFileService).downloadFile(cookie = eq("session=abc123; token=xyz"), urlString = any())
234+
}
235+
174236
private fun buildPendingDownload(
175237
url: String,
176238
contentDisposition: String? = null,
@@ -185,9 +247,9 @@ class UrlFileDownloaderTest {
185247
)
186248
}
187249

188-
private class FakeCookieManagerWrapper : CookieManagerWrapper {
250+
private class FakeCookieManagerWrapper(private val cookie: String? = null) : CookieManagerWrapper {
189251
override fun getCookie(url: String): String? {
190-
return null
252+
return cookie
191253
}
192254
}
193255
}

0 commit comments

Comments
 (0)