From 3321ef3f0230a938ce5e23980ca2065257e35dcb Mon Sep 17 00:00:00 2001 From: alperozturk Date: Mon, 13 Oct 2025 15:53:39 +0200 Subject: [PATCH 1/4] fix: duplicate key exception Signed-off-by: alperozturk --- .../java/com/nextcloud/android/sso/helper/Retrofit2Helper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java b/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java index 23f6b177..1bcc912e 100644 --- a/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java +++ b/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java @@ -56,7 +56,8 @@ public Response execute() { .stream() .collect(Collectors.toMap( AidlNetworkRequest.PlainHeader::getName, - AidlNetworkRequest.PlainHeader::getValue))) + AidlNetworkRequest.PlainHeader::getValue, + (existingValue, newValue) -> existingValue + ", " + newValue))) .orElse(Collections.emptyMap()); return Response.success(body, Headers.of(headerMap)); From a7fc2c10f72b74515ad862bf73912cbaba95f35f Mon Sep 17 00:00:00 2001 From: alperozturk Date: Tue, 14 Oct 2025 11:00:53 +0200 Subject: [PATCH 2/4] add: tests Signed-off-by: alperozturk --- lib/build.gradle | 8 + .../android/sso/api/AidlNetworkRequest.java | 2 +- .../android/sso/helper/Retrofit2Helper.java | 48 +++- .../android/sso/helper/Retrofit2HelperTest.kt | 233 ++++++++++++++++++ 4 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt diff --git a/lib/build.gradle b/lib/build.gradle index 4c149046..b3dd23ab 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -9,6 +9,7 @@ import com.github.spotbugs.snom.Confidence import com.github.spotbugs.snom.Effort import com.github.spotbugs.snom.SpotBugsTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget repositories { google() @@ -17,6 +18,7 @@ repositories { maven { url = 'https://plugins.gradle.org/m2/' } } +apply plugin: "kotlin-android" apply plugin: 'com.android.library' apply plugin: "com.github.spotbugs" apply plugin: "io.gitlab.arturbosch.detekt" @@ -61,6 +63,12 @@ android { targetCompatibility JavaVersion.VERSION_17 } + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } + } + publishing { singleVariant('release') { withSourcesJar() diff --git a/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java b/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java index f2e10073..bc79b59a 100644 --- a/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java +++ b/lib/src/main/java/com/nextcloud/android/sso/api/AidlNetworkRequest.java @@ -251,7 +251,7 @@ public static class PlainHeader implements Serializable { private String name; private String value; - PlainHeader(String name, String value) { + public PlainHeader(String name, String value) { this.name = name; this.value = value; } diff --git a/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java b/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java index 1bcc912e..0099526e 100644 --- a/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java +++ b/lib/src/main/java/com/nextcloud/android/sso/helper/Retrofit2Helper.java @@ -19,9 +19,10 @@ import java.lang.reflect.Type; import java.util.Arrays; -import java.util.Collections; +import java.util.HashSet; +import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; +import java.util.Set; import okhttp3.Headers; import okhttp3.Protocol; @@ -51,16 +52,8 @@ public Response execute() { try { final var response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest); final T body = nextcloudAPI.convertStreamToTargetEntity(response.getBody(), resType); - final var headerMap = Optional.ofNullable(response.getPlainHeaders()) - .map(headers -> headers - .stream() - .collect(Collectors.toMap( - AidlNetworkRequest.PlainHeader::getName, - AidlNetworkRequest.PlainHeader::getValue, - (existingValue, newValue) -> existingValue + ", " + newValue))) - .orElse(Collections.emptyMap()); - - return Response.success(body, Headers.of(headerMap)); + final var headers = buildHeaders(response.getPlainHeaders()); + return Response.success(body, headers); } catch (NextcloudHttpRequestFailedException e) { return convertExceptionToResponse(e.getStatusCode(), Optional.ofNullable(e.getCause()).orElse(e)); @@ -127,4 +120,35 @@ private Response convertExceptionToResponse(int statusCode, @NonNull Throwabl } }; } + + /** + * This preserves all distinct header values without combining them. + * + * @param plainHeaders List of headers from the response + * @return Headers object + */ + public static Headers buildHeaders(List plainHeaders) { + if (plainHeaders == null || plainHeaders.isEmpty()) { + return new Headers.Builder().build(); + } + + final Headers.Builder builder = new Headers.Builder(); + final Set seen = new HashSet<>(); + + for (var header : plainHeaders) { + final String name = header.getName(); + final String value = header.getValue(); + + // Create a unique key for name:value combination + final String key = name.toLowerCase() + ":" + value; + + // Only add if we haven't seen this exact name:value combination before + if (!seen.contains(key)) { + builder.add(name, value); + seen.add(key); + } + } + + return builder.build(); + } } diff --git a/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt new file mode 100644 index 00000000..2271c715 --- /dev/null +++ b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt @@ -0,0 +1,233 @@ +package com.nextcloud.android.sso.helper + +import com.nextcloud.android.sso.api.AidlNetworkRequest +import com.nextcloud.android.sso.helper.Retrofit2Helper.buildHeaders +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class Retrofit2HelperTest { + + @Test + fun testBuildHeadersWhenGivenNullHeadersShouldReturnEmptyHeaders() { + val headers = buildHeaders(null) + assertEquals(0, headers.size) + } + + @Test + fun testBuildHeadersWhenGivenEmptyHeadersListShouldReturnEmptyHeaders() { + val headers = buildHeaders(emptyList()) + assertEquals(0, headers.size) + } + + @Test + fun testBuildHeadersWhenGivenSingleHeaderShouldReturnThatHeader() { + val plainHeaders = listOf( + createPlainHeader("Content-Type", "application/json") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(1, headers.size) + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun testBuildHeadersWhenGivenDuplicateHeadersShouldRemoveDuplicates() { + val plainHeaders = listOf( + createPlainHeader("X-Robots-Tag", "noindex, nofollow"), + createPlainHeader("X-Robots-Tag", "noindex, nofollow"), + createPlainHeader("X-Robots-Tag", "noindex, nofollow") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(1, headers.size) + assertEquals("noindex, nofollow", headers["X-Robots-Tag"]) + } + + @Test + fun testBuildHeadersWhenGivenMultipleDistinctValuesShouldKeepAllValues() { + val plainHeaders = listOf( + createPlainHeader("Set-Cookie", "session=abc123"), + createPlainHeader("Set-Cookie", "token=xyz789"), + createPlainHeader("Set-Cookie", "user=john") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(3, headers.size) + val cookies = headers.values("Set-Cookie") + assertEquals(3, cookies.size) + assertTrue(cookies.contains("session=abc123")) + assertTrue(cookies.contains("token=xyz789")) + assertTrue(cookies.contains("user=john")) + } + + @Test + fun testBuildHeadersWhenGivenMixedDuplicateAndDistinctValuesShouldHandleCorrectly() { + val plainHeaders = listOf( + createPlainHeader("Set-Cookie", "session=abc"), + createPlainHeader("Set-Cookie", "session=abc"), + createPlainHeader("Set-Cookie", "token=xyz"), + createPlainHeader("X-Robots-Tag", "noindex"), + createPlainHeader("X-Robots-Tag", "noindex") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(3, headers.size) + + val cookies = headers.values("Set-Cookie") + assertEquals(2, cookies.size) + assertTrue(cookies.contains("session=abc")) + assertTrue(cookies.contains("token=xyz")) + + assertEquals("noindex", headers["X-Robots-Tag"]) + } + + @Test + fun testBuildHeadersWhenGivenCaseInsensitiveHeaderNamesShouldTreatAsSame() { + val plainHeaders = listOf( + createPlainHeader("Content-Type", "application/json"), + createPlainHeader("content-type", "application/json"), + createPlainHeader("CONTENT-TYPE", "application/json") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(1, headers.size) + assertEquals("application/json", headers["Content-Type"]) + } + + @Test + fun testBuildHeadersWhenGivenCaseInsensitiveHeaderNamesWithDifferentValuesShouldKeepAll() { + val plainHeaders = listOf( + createPlainHeader("Content-Type", "application/json"), + createPlainHeader("content-type", "text/html"), + createPlainHeader("CONTENT-TYPE", "application/xml") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(3, headers.size) + val values = headers.values("Content-Type") + assertTrue(values.contains("application/json")) + assertTrue(values.contains("text/html")) + assertTrue(values.contains("application/xml")) + } + + @Test + fun testBuildHeadersWhenGivenSameNameDifferentValuesShouldKeepAll() { + val plainHeaders = listOf( + createPlainHeader("Accept", "text/html"), + createPlainHeader("Accept", "application/json"), + createPlainHeader("Accept", "text/plain") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(3, headers.size) + val accepts = headers.values("Accept") + assertEquals(3, accepts.size) + assertTrue(accepts.contains("text/html")) + assertTrue(accepts.contains("application/json")) + assertTrue(accepts.contains("text/plain")) + } + + @Test + fun testBuildHeadersWhenGivenComplexScenarioShouldHandleAllCasesCorrectly() { + val plainHeaders = listOf( + createPlainHeader("Content-Type", "application/json"), + createPlainHeader("Set-Cookie", "session=abc"), + createPlainHeader("Set-Cookie", "token=xyz"), + createPlainHeader("Set-Cookie", "session=abc"), + createPlainHeader("X-Robots-Tag", "noindex, nofollow"), + createPlainHeader("X-Robots-Tag", "noindex, nofollow"), + createPlainHeader("Cache-Control", "no-cache"), + createPlainHeader("cache-control", "no-cache"), + createPlainHeader("Accept", "text/html"), + createPlainHeader("Accept", "application/json") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(7, headers.size) + + assertEquals("application/json", headers["Content-Type"]) + + val cookies = headers.values("Set-Cookie") + assertEquals(2, cookies.size) + assertTrue(cookies.contains("session=abc")) + assertTrue(cookies.contains("token=xyz")) + + assertEquals("noindex, nofollow", headers["X-Robots-Tag"]) + assertEquals("no-cache", headers["Cache-Control"]) + + val accepts = headers.values("Accept") + assertEquals(2, accepts.size) + assertTrue(accepts.contains("text/html")) + assertTrue(accepts.contains("application/json")) + } + + @Test + fun testBuildHeadersWhenGivenHeadersWithWhitespaceShouldPreserveExactValue() { + val plainHeaders = listOf( + createPlainHeader("X-Custom", "value with spaces"), + createPlainHeader("X-Custom", "value with spaces") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(1, headers.size) + assertEquals("value with spaces", headers["X-Custom"]) + } + + @Test + fun testBuildHeadersWhenGivenHeadersWithSpecialCharactersShouldPreserveExactValue() { + val plainHeaders = listOf( + createPlainHeader("Authorization", "Bearer eyJhbGc..."), + createPlainHeader("X-Special", "value=test;path=/;secure") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(2, headers.size) + assertEquals("Bearer eyJhbGc...", headers["Authorization"]) + assertEquals("value=test;path=/;secure", headers["X-Special"]) + } + + @Test + fun testBuildHeadersWhenGivenEmptyHeaderValueShouldHandleCorrectly() { + val plainHeaders = listOf( + createPlainHeader("X-Empty", ""), + createPlainHeader("X-Empty", "") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(1, headers.size) + assertEquals("", headers["X-Empty"]) + } + + @Test + fun testBuildHeadersWhenGivenMultipleHeadersWithSomeEmptyValuesShouldHandleCorrectly() { + val plainHeaders = listOf( + createPlainHeader("X-Test", "value1"), + createPlainHeader("X-Test", ""), + createPlainHeader("X-Test", "value2"), + createPlainHeader("X-Test", "") + ) + + val headers = buildHeaders(plainHeaders) + + assertEquals(3, headers.size) + val values = headers.values("X-Test") + assertEquals(3, values.size) + assertTrue(values.contains("value1")) + assertTrue(values.contains("")) + assertTrue(values.contains("value2")) + } + + private fun createPlainHeader(name: String, value: String) = AidlNetworkRequest.PlainHeader(name, value) +} From b81212b2fbdd33a9966f538a2335e572884c6cd9 Mon Sep 17 00:00:00 2001 From: alperozturk Date: Tue, 14 Oct 2025 11:02:48 +0200 Subject: [PATCH 3/4] add: license header Signed-off-by: alperozturk --- .../nextcloud/android/sso/helper/Retrofit2HelperTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt index 2271c715..e1a4108f 100644 --- a/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt +++ b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt @@ -1,3 +1,10 @@ +/* + * Nextcloud Android SingleSignOn Library + * + * SPDX-FileCopyrightText: 2017-2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2025 Alper Ozturk + * SPDX-License-Identifier: GPL-3.0-or-later + */ package com.nextcloud.android.sso.helper import com.nextcloud.android.sso.api.AidlNetworkRequest From 94737346784b08538d30844bf2b6b89e102936ec Mon Sep 17 00:00:00 2001 From: alperozturk Date: Tue, 14 Oct 2025 11:05:39 +0200 Subject: [PATCH 4/4] fix: codacy Signed-off-by: alperozturk --- .../java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt index e1a4108f..2d2e22ad 100644 --- a/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt +++ b/lib/src/test/java/com/nextcloud/android/sso/helper/Retrofit2HelperTest.kt @@ -13,6 +13,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +@Suppress("MagicNumber", "TooManyFunctions") class Retrofit2HelperTest { @Test