Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"
Expand Down Expand Up @@ -61,6 +63,12 @@ android {
targetCompatibility JavaVersion.VERSION_17
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}

publishing {
singleVariant('release') {
withSourcesJar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,15 +52,8 @@ public Response<T> 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)))
.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));
Expand Down Expand Up @@ -126,4 +120,35 @@ private Response<T> 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<AidlNetworkRequest.PlainHeader> plainHeaders) {
if (plainHeaders == null || plainHeaders.isEmpty()) {
return new Headers.Builder().build();
}

final Headers.Builder builder = new Headers.Builder();
final Set<String> 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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

This could fix downstream issues like e.g. nextcloud/notes-android#2531

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

This could fix downstream issues like e.g. nextcloud/notes-android#2531

I don't think so. It doesn't actually store the headers with the lowercase key, only uses that for deduplicating them.

For that issue, I did comment on #796 with a likely solution, but I didn't manage to build Notes against my test version to find out.


// 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*
* Nextcloud Android SingleSignOn Library
*
* SPDX-FileCopyrightText: 2017-2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2025 Alper Ozturk <[email protected]>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
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

@Suppress("MagicNumber", "TooManyFunctions")
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)
}