From a68134fdbc04d109f4d3517f966a0be60f5cf06f Mon Sep 17 00:00:00 2001 From: David Luhmer Date: Sun, 19 Jul 2026 17:45:04 +0200 Subject: [PATCH] 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 Signed-off-by: David Luhmer --- .../android/sso/api/AidlNetworkRequest.java | 20 +++- .../android/sso/api/AidlNetworkRequestTest.kt | 103 ++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.kt 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 bc79b59a..aec1b73f 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 @@ -25,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.gson.Gson; import com.google.gson.internal.LinkedTreeMap; @@ -229,10 +230,23 @@ private static T deserializeObject(InputStream is) throws IOException, Class return (T) new ObjectInputStream(is).readObject(); } - private ExceptionResponse deserializeObjectV2(InputStream is) throws IOException, ClassNotFoundException { + @VisibleForTesting + static ExceptionResponse deserializeObjectV2(InputStream is) throws IOException, ClassNotFoundException { final ObjectInputStream ois = new ObjectInputStream(is); final ArrayList headerList = new ArrayList<>(); - final Exception exception = (Exception) ois.readObject(); + Exception exception; + try { + exception = (Exception) ois.readObject(); + } catch (ClassNotFoundException e) { + // The server app serializes the original exception object. If its class - or any + // class in its cause chain - only exists in the server app (e.g. + // com.owncloud.android.lib.common.network.CertificateCombinedException on SSL errors), + // deserialization fails here. Surface a plain exception carrying the original type + // name instead of a ClassNotFoundException that hides the actual error. + exception = new Exception("The Nextcloud Files app responded with an error that could not" + + " be read by this app: " + e.getMessage() + + ". Check the server connection (e.g. SSL certificate) or the Files app log for the actual error."); + } if (exception == null) { final String headers = (String) ois.readObject(); @@ -275,7 +289,7 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE } } - private record ExceptionResponse( + record ExceptionResponse( @NonNull ArrayList headers, @Nullable Exception exception ) { diff --git a/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.kt b/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.kt new file mode 100644 index 00000000..56ffce25 --- /dev/null +++ b/lib/src/test/java/com/nextcloud/android/sso/api/AidlNetworkRequestTest.kt @@ -0,0 +1,103 @@ +/* + * Nextcloud Android SingleSignOn Library + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.android.sso.api + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectOutputStream +import java.nio.charset.StandardCharsets + +class AidlNetworkRequestTest { + + /** + * Exception class only used to be renamed in the serialized stream, simulating an exception + * type that exists in the Files app but not in the client app (e.g. + * `com.owncloud.android.lib.common.network.CertificateCombinedException`). + */ + class OnlyInFilesAppException(message: String) : Exception(message) + + @Test + fun deserializeObjectV2ExceptionClassMissingInClientAppReturnsReadableException() { + val data = serialize(OnlyInFilesAppException("certificate expired"), "[]") + + // Rename the class inside the serialized stream to one that does not exist on this + // side of the IPC channel (same length, so the stream structure stays intact) + val tampered = replaceBytes(data, "OnlyInFilesAppException", "OnlyInF1lesAppException") + + val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(tampered)) + + val exception = response.exception() + assertNotNull(exception) + assertFalse(exception is ClassNotFoundException) + assertNotNull(exception!!.message) + assertTrue( + "message should contain the original exception type", + exception.message!!.contains("OnlyInF1lesAppException") + ) + } + + @Test + fun deserializeObjectV2NoExceptionParsesHeaders() { + val data = serialize(null, "[{\"name\":\"Content-Type\",\"value\":\"application/json\"}]") + + val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(data)) + + assertNull(response.exception()) + assertEquals(1, response.headers().size) + assertEquals("Content-Type", response.headers()[0].name) + assertEquals("application/json", response.headers()[0].value) + } + + @Test + fun deserializeObjectV2KnownExceptionReturnsItUnchanged() { + val data = serialize(IllegalStateException("CE_1"), "[]") + + val response = AidlNetworkRequest.deserializeObjectV2(ByteArrayInputStream(data)) + + assertTrue(response.exception() is IllegalStateException) + assertEquals("CE_1", response.exception()?.message) + } + + /** Mirrors `InputStreamBinder#serializeObjectToInputStreamV2` in the Files app. */ + private fun serialize(exception: Exception?, headers: String): ByteArray { + val baos = ByteArrayOutputStream() + ObjectOutputStream(baos).use { oos -> + oos.writeObject(exception) + oos.writeObject(headers) + } + return baos.toByteArray() + } + + private fun replaceBytes(data: ByteArray, search: String, replace: String): ByteArray { + val searchBytes = search.toByteArray(StandardCharsets.US_ASCII) + val replaceBytes = replace.toByteArray(StandardCharsets.US_ASCII) + assertEquals(searchBytes.size, replaceBytes.size) + + val result = data.clone() + var i = 0 + while (i <= result.size - searchBytes.size) { + var match = true + for (j in searchBytes.indices) { + if (result[i + j] != searchBytes[j]) { + match = false + break + } + } + if (match) { + System.arraycopy(replaceBytes, 0, result, i, replaceBytes.size) + } + i++ + } + return result + } +}