Skip to content

Commit a68134f

Browse files
fix: surface readable error when server app exception cannot be deserialized
The Files app serializes the original exception object over the AIDL pipe. If its class - or any class in its cause chain - only exists in the Files app (e.g. CertificateCombinedException from the owncloud library on SSL errors), ObjectInputStream#readObject throws a ClassNotFoundException in the client app, hiding the actual error behind a message like: java.lang.ClassNotFoundException: com.owncloud.android.lib.common.net... Catch the ClassNotFoundException during deserialization and replace it with a plain exception carrying the original type name and a hint to check the server connection. It flows through the existing parseNextcloudCustomException translation, so client apps get a proper SSOException (UnknownErrorException) instead of a raw ClassNotFoundException. Ref nextcloud/news-android#1645 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: David Luhmer <david-dev@live.de>
1 parent c2b585a commit a68134f

2 files changed

Lines changed: 120 additions & 3 deletions

File tree

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import androidx.annotation.NonNull;
2727
import androidx.annotation.Nullable;
28+
import androidx.annotation.VisibleForTesting;
2829

2930
import com.google.gson.Gson;
3031
import com.google.gson.internal.LinkedTreeMap;
@@ -229,10 +230,23 @@ private static <T> T deserializeObject(InputStream is) throws IOException, Class
229230
return (T) new ObjectInputStream(is).readObject();
230231
}
231232

232-
private ExceptionResponse deserializeObjectV2(InputStream is) throws IOException, ClassNotFoundException {
233+
@VisibleForTesting
234+
static ExceptionResponse deserializeObjectV2(InputStream is) throws IOException, ClassNotFoundException {
233235
final ObjectInputStream ois = new ObjectInputStream(is);
234236
final ArrayList<PlainHeader> headerList = new ArrayList<>();
235-
final Exception exception = (Exception) ois.readObject();
237+
Exception exception;
238+
try {
239+
exception = (Exception) ois.readObject();
240+
} catch (ClassNotFoundException e) {
241+
// The server app serializes the original exception object. If its class - or any
242+
// class in its cause chain - only exists in the server app (e.g.
243+
// com.owncloud.android.lib.common.network.CertificateCombinedException on SSL errors),
244+
// deserialization fails here. Surface a plain exception carrying the original type
245+
// name instead of a ClassNotFoundException that hides the actual error.
246+
exception = new Exception("The Nextcloud Files app responded with an error that could not"
247+
+ " be read by this app: " + e.getMessage()
248+
+ ". Check the server connection (e.g. SSL certificate) or the Files app log for the actual error.");
249+
}
236250

237251
if (exception == null) {
238252
final String headers = (String) ois.readObject();
@@ -275,7 +289,7 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE
275289
}
276290
}
277291

278-
private record ExceptionResponse(
292+
record ExceptionResponse(
279293
@NonNull ArrayList<PlainHeader> headers,
280294
@Nullable Exception exception
281295
) {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Nextcloud Android SingleSignOn Library
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package com.nextcloud.android.sso.api
8+
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertFalse
11+
import org.junit.Assert.assertNotNull
12+
import org.junit.Assert.assertNull
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Test
15+
import java.io.ByteArrayInputStream
16+
import java.io.ByteArrayOutputStream
17+
import java.io.ObjectOutputStream
18+
import java.nio.charset.StandardCharsets
19+
20+
class AidlNetworkRequestTest {
21+
22+
/**
23+
* Exception class only used to be renamed in the serialized stream, simulating an exception
24+
* type that exists in the Files app but not in the client app (e.g.
25+
* `com.owncloud.android.lib.common.network.CertificateCombinedException`).
26+
*/
27+
class OnlyInFilesAppException(message: String) : Exception(message)
28+
29+
@Test
30+
fun deserializeObjectV2ExceptionClassMissingInClientAppReturnsReadableException() {
31+
val data = serialize(OnlyInFilesAppException("certificate expired"), "[]")
32+
33+
// Rename the class inside the serialized stream to one that does not exist on this
34+
// side of the IPC channel (same length, so the stream structure stays intact)
35+
val tampered = replaceBytes(data, "OnlyInFilesAppException", "OnlyInF1lesAppException")
36+
37+
val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(tampered))
38+
39+
val exception = response.exception()
40+
assertNotNull(exception)
41+
assertFalse(exception is ClassNotFoundException)
42+
assertNotNull(exception!!.message)
43+
assertTrue(
44+
"message should contain the original exception type",
45+
exception.message!!.contains("OnlyInF1lesAppException")
46+
)
47+
}
48+
49+
@Test
50+
fun deserializeObjectV2NoExceptionParsesHeaders() {
51+
val data = serialize(null, "[{\"name\":\"Content-Type\",\"value\":\"application/json\"}]")
52+
53+
val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(data))
54+
55+
assertNull(response.exception())
56+
assertEquals(1, response.headers().size)
57+
assertEquals("Content-Type", response.headers()[0].name)
58+
assertEquals("application/json", response.headers()[0].value)
59+
}
60+
61+
@Test
62+
fun deserializeObjectV2KnownExceptionReturnsItUnchanged() {
63+
val data = serialize(IllegalStateException("CE_1"), "[]")
64+
65+
val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(data))
66+
67+
assertTrue(response.exception() is IllegalStateException)
68+
assertEquals("CE_1", response.exception()?.message)
69+
}
70+
71+
/** Mirrors `InputStreamBinder#serializeObjectToInputStreamV2` in the Files app. */
72+
private fun serialize(exception: Exception?, headers: String): ByteArray {
73+
val baos = ByteArrayOutputStream()
74+
ObjectOutputStream(baos).use { oos ->
75+
oos.writeObject(exception)
76+
oos.writeObject(headers)
77+
}
78+
return baos.toByteArray()
79+
}
80+
81+
private fun replaceBytes(data: ByteArray, search: String, replace: String): ByteArray {
82+
val searchBytes = search.toByteArray(StandardCharsets.US_ASCII)
83+
val replaceBytes = replace.toByteArray(StandardCharsets.US_ASCII)
84+
assertEquals(searchBytes.size, replaceBytes.size)
85+
86+
val result = data.clone()
87+
var i = 0
88+
while (i <= result.size - searchBytes.size) {
89+
var match = true
90+
for (j in searchBytes.indices) {
91+
if (result[i + j] != searchBytes[j]) {
92+
match = false
93+
break
94+
}
95+
}
96+
if (match) {
97+
System.arraycopy(replaceBytes, 0, result, i, replaceBytes.size)
98+
}
99+
i++
100+
}
101+
return result
102+
}
103+
}

0 commit comments

Comments
 (0)