Skip to content

Commit 42cb74f

Browse files
Merge pull request #907 from nextcloud/fix/duplicate-key-exception
fix: duplicate key exception
2 parents c446582 + 9473734 commit 42cb74f

File tree

4 files changed

+286
-12
lines changed

4 files changed

+286
-12
lines changed

lib/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.github.spotbugs.snom.Confidence
1010
import com.github.spotbugs.snom.Effort
1111
import com.github.spotbugs.snom.SpotBugsTask
12+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
1213

1314
repositories {
1415
google()
@@ -17,6 +18,7 @@ repositories {
1718
maven { url = 'https://plugins.gradle.org/m2/' }
1819
}
1920

21+
apply plugin: "kotlin-android"
2022
apply plugin: 'com.android.library'
2123
apply plugin: "com.github.spotbugs"
2224
apply plugin: "io.gitlab.arturbosch.detekt"
@@ -61,6 +63,12 @@ android {
6163
targetCompatibility JavaVersion.VERSION_17
6264
}
6365

66+
kotlin {
67+
compilerOptions {
68+
jvmTarget = JvmTarget.JVM_17
69+
}
70+
}
71+
6472
publishing {
6573
singleVariant('release') {
6674
withSourcesJar()

lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ public static class PlainHeader implements Serializable {
251251
private String name;
252252
private String value;
253253

254-
PlainHeader(String name, String value) {
254+
public PlainHeader(String name, String value) {
255255
this.name = name;
256256
this.value = value;
257257
}

lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919

2020
import java.lang.reflect.Type;
2121
import java.util.Arrays;
22-
import java.util.Collections;
22+
import java.util.HashSet;
23+
import java.util.List;
2324
import java.util.Optional;
24-
import java.util.stream.Collectors;
25+
import java.util.Set;
2526

2627
import okhttp3.Headers;
2728
import okhttp3.Protocol;
@@ -51,15 +52,8 @@ public Response<T> execute() {
5152
try {
5253
final var response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest);
5354
final T body = nextcloudAPI.convertStreamToTargetEntity(response.getBody(), resType);
54-
final var headerMap = Optional.ofNullable(response.getPlainHeaders())
55-
.map(headers -> headers
56-
.stream()
57-
.collect(Collectors.toMap(
58-
AidlNetworkRequest.PlainHeader::getName,
59-
AidlNetworkRequest.PlainHeader::getValue)))
60-
.orElse(Collections.emptyMap());
61-
62-
return Response.success(body, Headers.of(headerMap));
55+
final var headers = buildHeaders(response.getPlainHeaders());
56+
return Response.success(body, headers);
6357

6458
} catch (NextcloudHttpRequestFailedException e) {
6559
return convertExceptionToResponse(e.getStatusCode(), Optional.ofNullable(e.getCause()).orElse(e));
@@ -126,4 +120,35 @@ private Response<T> convertExceptionToResponse(int statusCode, @NonNull Throwabl
126120
}
127121
};
128122
}
123+
124+
/**
125+
* This preserves all distinct header values without combining them.
126+
*
127+
* @param plainHeaders List of headers from the response
128+
* @return Headers object
129+
*/
130+
public static Headers buildHeaders(List<AidlNetworkRequest.PlainHeader> plainHeaders) {
131+
if (plainHeaders == null || plainHeaders.isEmpty()) {
132+
return new Headers.Builder().build();
133+
}
134+
135+
final Headers.Builder builder = new Headers.Builder();
136+
final Set<String> seen = new HashSet<>();
137+
138+
for (var header : plainHeaders) {
139+
final String name = header.getName();
140+
final String value = header.getValue();
141+
142+
// Create a unique key for name:value combination
143+
final String key = name.toLowerCase() + ":" + value;
144+
145+
// Only add if we haven't seen this exact name:value combination before
146+
if (!seen.contains(key)) {
147+
builder.add(name, value);
148+
seen.add(key);
149+
}
150+
}
151+
152+
return builder.build();
153+
}
129154
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
* Nextcloud Android SingleSignOn Library
3+
*
4+
* SPDX-FileCopyrightText: 2017-2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
6+
* SPDX-License-Identifier: GPL-3.0-or-later
7+
*/
8+
package com.nextcloud.android.sso.helper
9+
10+
import com.nextcloud.android.sso.api.AidlNetworkRequest
11+
import com.nextcloud.android.sso.helper.Retrofit2Helper.buildHeaders
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
16+
@Suppress("MagicNumber", "TooManyFunctions")
17+
class Retrofit2HelperTest {
18+
19+
@Test
20+
fun testBuildHeadersWhenGivenNullHeadersShouldReturnEmptyHeaders() {
21+
val headers = buildHeaders(null)
22+
assertEquals(0, headers.size)
23+
}
24+
25+
@Test
26+
fun testBuildHeadersWhenGivenEmptyHeadersListShouldReturnEmptyHeaders() {
27+
val headers = buildHeaders(emptyList())
28+
assertEquals(0, headers.size)
29+
}
30+
31+
@Test
32+
fun testBuildHeadersWhenGivenSingleHeaderShouldReturnThatHeader() {
33+
val plainHeaders = listOf(
34+
createPlainHeader("Content-Type", "application/json")
35+
)
36+
37+
val headers = buildHeaders(plainHeaders)
38+
39+
assertEquals(1, headers.size)
40+
assertEquals("application/json", headers["Content-Type"])
41+
}
42+
43+
@Test
44+
fun testBuildHeadersWhenGivenDuplicateHeadersShouldRemoveDuplicates() {
45+
val plainHeaders = listOf(
46+
createPlainHeader("X-Robots-Tag", "noindex, nofollow"),
47+
createPlainHeader("X-Robots-Tag", "noindex, nofollow"),
48+
createPlainHeader("X-Robots-Tag", "noindex, nofollow")
49+
)
50+
51+
val headers = buildHeaders(plainHeaders)
52+
53+
assertEquals(1, headers.size)
54+
assertEquals("noindex, nofollow", headers["X-Robots-Tag"])
55+
}
56+
57+
@Test
58+
fun testBuildHeadersWhenGivenMultipleDistinctValuesShouldKeepAllValues() {
59+
val plainHeaders = listOf(
60+
createPlainHeader("Set-Cookie", "session=abc123"),
61+
createPlainHeader("Set-Cookie", "token=xyz789"),
62+
createPlainHeader("Set-Cookie", "user=john")
63+
)
64+
65+
val headers = buildHeaders(plainHeaders)
66+
67+
assertEquals(3, headers.size)
68+
val cookies = headers.values("Set-Cookie")
69+
assertEquals(3, cookies.size)
70+
assertTrue(cookies.contains("session=abc123"))
71+
assertTrue(cookies.contains("token=xyz789"))
72+
assertTrue(cookies.contains("user=john"))
73+
}
74+
75+
@Test
76+
fun testBuildHeadersWhenGivenMixedDuplicateAndDistinctValuesShouldHandleCorrectly() {
77+
val plainHeaders = listOf(
78+
createPlainHeader("Set-Cookie", "session=abc"),
79+
createPlainHeader("Set-Cookie", "session=abc"),
80+
createPlainHeader("Set-Cookie", "token=xyz"),
81+
createPlainHeader("X-Robots-Tag", "noindex"),
82+
createPlainHeader("X-Robots-Tag", "noindex")
83+
)
84+
85+
val headers = buildHeaders(plainHeaders)
86+
87+
assertEquals(3, headers.size)
88+
89+
val cookies = headers.values("Set-Cookie")
90+
assertEquals(2, cookies.size)
91+
assertTrue(cookies.contains("session=abc"))
92+
assertTrue(cookies.contains("token=xyz"))
93+
94+
assertEquals("noindex", headers["X-Robots-Tag"])
95+
}
96+
97+
@Test
98+
fun testBuildHeadersWhenGivenCaseInsensitiveHeaderNamesShouldTreatAsSame() {
99+
val plainHeaders = listOf(
100+
createPlainHeader("Content-Type", "application/json"),
101+
createPlainHeader("content-type", "application/json"),
102+
createPlainHeader("CONTENT-TYPE", "application/json")
103+
)
104+
105+
val headers = buildHeaders(plainHeaders)
106+
107+
assertEquals(1, headers.size)
108+
assertEquals("application/json", headers["Content-Type"])
109+
}
110+
111+
@Test
112+
fun testBuildHeadersWhenGivenCaseInsensitiveHeaderNamesWithDifferentValuesShouldKeepAll() {
113+
val plainHeaders = listOf(
114+
createPlainHeader("Content-Type", "application/json"),
115+
createPlainHeader("content-type", "text/html"),
116+
createPlainHeader("CONTENT-TYPE", "application/xml")
117+
)
118+
119+
val headers = buildHeaders(plainHeaders)
120+
121+
assertEquals(3, headers.size)
122+
val values = headers.values("Content-Type")
123+
assertTrue(values.contains("application/json"))
124+
assertTrue(values.contains("text/html"))
125+
assertTrue(values.contains("application/xml"))
126+
}
127+
128+
@Test
129+
fun testBuildHeadersWhenGivenSameNameDifferentValuesShouldKeepAll() {
130+
val plainHeaders = listOf(
131+
createPlainHeader("Accept", "text/html"),
132+
createPlainHeader("Accept", "application/json"),
133+
createPlainHeader("Accept", "text/plain")
134+
)
135+
136+
val headers = buildHeaders(plainHeaders)
137+
138+
assertEquals(3, headers.size)
139+
val accepts = headers.values("Accept")
140+
assertEquals(3, accepts.size)
141+
assertTrue(accepts.contains("text/html"))
142+
assertTrue(accepts.contains("application/json"))
143+
assertTrue(accepts.contains("text/plain"))
144+
}
145+
146+
@Test
147+
fun testBuildHeadersWhenGivenComplexScenarioShouldHandleAllCasesCorrectly() {
148+
val plainHeaders = listOf(
149+
createPlainHeader("Content-Type", "application/json"),
150+
createPlainHeader("Set-Cookie", "session=abc"),
151+
createPlainHeader("Set-Cookie", "token=xyz"),
152+
createPlainHeader("Set-Cookie", "session=abc"),
153+
createPlainHeader("X-Robots-Tag", "noindex, nofollow"),
154+
createPlainHeader("X-Robots-Tag", "noindex, nofollow"),
155+
createPlainHeader("Cache-Control", "no-cache"),
156+
createPlainHeader("cache-control", "no-cache"),
157+
createPlainHeader("Accept", "text/html"),
158+
createPlainHeader("Accept", "application/json")
159+
)
160+
161+
val headers = buildHeaders(plainHeaders)
162+
163+
assertEquals(7, headers.size)
164+
165+
assertEquals("application/json", headers["Content-Type"])
166+
167+
val cookies = headers.values("Set-Cookie")
168+
assertEquals(2, cookies.size)
169+
assertTrue(cookies.contains("session=abc"))
170+
assertTrue(cookies.contains("token=xyz"))
171+
172+
assertEquals("noindex, nofollow", headers["X-Robots-Tag"])
173+
assertEquals("no-cache", headers["Cache-Control"])
174+
175+
val accepts = headers.values("Accept")
176+
assertEquals(2, accepts.size)
177+
assertTrue(accepts.contains("text/html"))
178+
assertTrue(accepts.contains("application/json"))
179+
}
180+
181+
@Test
182+
fun testBuildHeadersWhenGivenHeadersWithWhitespaceShouldPreserveExactValue() {
183+
val plainHeaders = listOf(
184+
createPlainHeader("X-Custom", "value with spaces"),
185+
createPlainHeader("X-Custom", "value with spaces")
186+
)
187+
188+
val headers = buildHeaders(plainHeaders)
189+
190+
assertEquals(1, headers.size)
191+
assertEquals("value with spaces", headers["X-Custom"])
192+
}
193+
194+
@Test
195+
fun testBuildHeadersWhenGivenHeadersWithSpecialCharactersShouldPreserveExactValue() {
196+
val plainHeaders = listOf(
197+
createPlainHeader("Authorization", "Bearer eyJhbGc..."),
198+
createPlainHeader("X-Special", "value=test;path=/;secure")
199+
)
200+
201+
val headers = buildHeaders(plainHeaders)
202+
203+
assertEquals(2, headers.size)
204+
assertEquals("Bearer eyJhbGc...", headers["Authorization"])
205+
assertEquals("value=test;path=/;secure", headers["X-Special"])
206+
}
207+
208+
@Test
209+
fun testBuildHeadersWhenGivenEmptyHeaderValueShouldHandleCorrectly() {
210+
val plainHeaders = listOf(
211+
createPlainHeader("X-Empty", ""),
212+
createPlainHeader("X-Empty", "")
213+
)
214+
215+
val headers = buildHeaders(plainHeaders)
216+
217+
assertEquals(1, headers.size)
218+
assertEquals("", headers["X-Empty"])
219+
}
220+
221+
@Test
222+
fun testBuildHeadersWhenGivenMultipleHeadersWithSomeEmptyValuesShouldHandleCorrectly() {
223+
val plainHeaders = listOf(
224+
createPlainHeader("X-Test", "value1"),
225+
createPlainHeader("X-Test", ""),
226+
createPlainHeader("X-Test", "value2"),
227+
createPlainHeader("X-Test", "")
228+
)
229+
230+
val headers = buildHeaders(plainHeaders)
231+
232+
assertEquals(3, headers.size)
233+
val values = headers.values("X-Test")
234+
assertEquals(3, values.size)
235+
assertTrue(values.contains("value1"))
236+
assertTrue(values.contains(""))
237+
assertTrue(values.contains("value2"))
238+
}
239+
240+
private fun createPlainHeader(name: String, value: String) = AidlNetworkRequest.PlainHeader(name, value)
241+
}

0 commit comments

Comments
 (0)