Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,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)
}