From 9441060f0b92f6a4de6e24138e52db59afa02164 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 27 Apr 2026 11:59:18 -0400 Subject: [PATCH 1/2] fix(Capabilities): make deserialization defensive and schema-tolerant Fixes #3165 Fixes #2815 ...and other already closed duplicate bug reports. Signed-off-by: Josh --- .../sync/CapabilitiesDeserializer.java | 192 ++++++++++++------ 1 file changed, 125 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java index e814d07be..b443ffc05 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -49,93 +49,151 @@ public class CapabilitiesDeserializer implements JsonDeserializer public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { final var response = new Capabilities(); final var data = json.getAsJsonObject(); - if (data.has(VERSION)) { - final var version = data.getAsJsonObject(VERSION); - final var nextcloudMajorVersion = version.get("major"); - response.setNextcloudMajorVersion(String.valueOf(nextcloudMajorVersion)); - final var nextcloudMinorVersion = version.get("minor"); - response.setNextcloudMinorVersion(String.valueOf(nextcloudMinorVersion)); + deserializeVersion(data, response); - - final var nextcloudMicroVersion = version.get("micro"); - response.setNextcloudMicroVersion(String.valueOf(nextcloudMicroVersion)); + if (!data.has(CAPABILITIES)) { + return response; } - if (data.has(CAPABILITIES)) { - final var capabilities = data.getAsJsonObject(CAPABILITIES); + final var capabilities = data.getAsJsonObject(CAPABILITIES); + + deserializeFilesSharing(capabilities, response); + deserializeNotes(capabilities, response); + deserializeTheming(capabilities, response); + deserializeDirectEditing(capabilities, response); + deserializeUserStatus(capabilities, response); - if (capabilities.has(CAPABILITIES_FILES_SHARING)) { - final var filesSharing = capabilities.getAsJsonObject(CAPABILITIES_FILES_SHARING); - final var federation = filesSharing.getAsJsonObject("federation"); - final var outgoing = federation.get("outgoing"); + return response; + } - response.setFederationShare(outgoing.getAsBoolean()); + private void deserializeVersion(final JsonObject data, final Capabilities response) { + if (!data.has(VERSION)) { + return; + } - final var publicObject = filesSharing.getAsJsonObject("public"); - if (publicObject.has("password")) { - final var password = publicObject.getAsJsonObject("password"); - final var enforced = password.getAsJsonPrimitive("enforced"); - final var askForOptionalPassword = password.getAsJsonPrimitive("askForOptionalPassword"); + final var version = data.getAsJsonObject(VERSION); - response.setPublicPasswordEnforced(enforced.getAsBoolean()); - response.setAskForOptionalPassword(askForOptionalPassword.getAsBoolean()); - } + if (version.has("major")) { + response.setNextcloudMajorVersion(String.valueOf(version.get("major"))); + } + if (version.has("minor")) { + response.setNextcloudMinorVersion(String.valueOf(version.get("minor"))); + } + if (version.has("micro")) { + response.setNextcloudMicroVersion(String.valueOf(version.get("micro"))); + } + } + + private void deserializeFilesSharing(final JsonObject capabilities, final Capabilities response) { + if (!capabilities.has(CAPABILITIES_FILES_SHARING)) { + return; + } - final var isReSharingAllowed = filesSharing.getAsJsonPrimitive("resharing"); - final var defaultPermission = filesSharing.getAsJsonPrimitive("default_permissions"); - response.setDefaultPermission(defaultPermission.getAsInt()); - response.setReSharingAllowed(isReSharingAllowed.getAsBoolean()); + final var filesSharing = capabilities.getAsJsonObject(CAPABILITIES_FILES_SHARING); + + if (filesSharing.has("federation")) { + final var federation = filesSharing.getAsJsonObject("federation"); + if (federation.has("outgoing")) { + response.setFederationShare(federation.get("outgoing").getAsBoolean()); } + } - if (capabilities.has(CAPABILITIES_NOTES)) { - final var notes = capabilities.getAsJsonObject(CAPABILITIES_NOTES); - if (notes.has(CAPABILITIES_NOTES_API_VERSION)) { - response.setApiVersion(notes.get(CAPABILITIES_NOTES_API_VERSION).toString()); - } + if (filesSharing.has("api_enabled")) { + final var shareApiEnabled = filesSharing.get("api_enabled"); + if (!shareApiEnabled.getAsBoolean()) { + return; } - if (capabilities.has(CAPABILITIES_THEMING)) { - final var theming = capabilities.getAsJsonObject(CAPABILITIES_THEMING); - if (theming.has(CAPABILITIES_THEMING_COLOR)) { - try { - response.setColor(Color.parseColor(ColorUtil.formatColorToParsableHexString(theming.get(CAPABILITIES_THEMING_COLOR).getAsString()))); - } catch (Exception e) { - e.printStackTrace(); - } + } + + if (filesSharing.has("public")) { + final var publicObject = filesSharing.getAsJsonObject("public"); + if (publicObject.has("password")) { + final var password = publicObject.getAsJsonObject("password"); + if (password.has("enforced")) { + response.setPublicPasswordEnforced(password.getAsJsonPrimitive("enforced").getAsBoolean()); } - if (theming.has(CAPABILITIES_THEMING_COLOR_TEXT)) { - try { - response.setTextColor(Color.parseColor(ColorUtil.formatColorToParsableHexString(theming.get(CAPABILITIES_THEMING_COLOR_TEXT).getAsString()))); - } catch (Exception e) { - e.printStackTrace(); - } + if (password.has("askForOptionalPassword")) { + response.setAskForOptionalPassword(password.getAsJsonPrimitive("askForOptionalPassword").getAsBoolean()); } } - response.setDirectEditingAvailable(hasDirectEditingCapability(capabilities)); - - if (capabilities.has(CAPABILITIES_USER_STATUS)) { - final var userStatus = capabilities.getAsJsonObject(CAPABILITIES_USER_STATUS); - if (userStatus.has(CAPABILITIES_SUPPORTS_BUSY)) { - final var userStatusSupportsBusy = userStatus.getAsJsonPrimitive(CAPABILITIES_SUPPORTS_BUSY); - if (userStatusSupportsBusy != null) { - response.setUserStatusSupportsBusy(userStatusSupportsBusy.getAsBoolean()); - } - } + } + + if (filesSharing.has("resharing")) { + response.setReSharingAllowed(filesSharing.getAsJsonPrimitive("resharing").getAsBoolean()); + } + + if (filesSharing.has("default_permissions")) { + response.setDefaultPermission(filesSharing.getAsJsonPrimitive("default_permissions").getAsInt()); + } + } + + private void deserializeNotes(final JsonObject capabilities, final Capabilities response) { + if (!capabilities.has(CAPABILITIES_NOTES)) { + return; + } + + final var notes = capabilities.getAsJsonObject(CAPABILITIES_NOTES); + if (notes.has(CAPABILITIES_NOTES_API_VERSION)) { + response.setApiVersion(notes.get(CAPABILITIES_NOTES_API_VERSION).toString()); + } + } + + private void deserializeTheming(final JsonObject capabilities, final Capabilities response) { + if (!capabilities.has(CAPABILITIES_THEMING)) { + return; + } + + final var theming = capabilities.getAsJsonObject(CAPABILITIES_THEMING); + if (theming.has(CAPABILITIES_THEMING_COLOR)) { + try { + response.setColor(Color.parseColor(ColorUtil.formatColorToParsableHexString( + theming.get(CAPABILITIES_THEMING_COLOR).getAsString() + ))); + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (theming.has(CAPABILITIES_THEMING_COLOR_TEXT)) { + try { + response.setTextColor(Color.parseColor(ColorUtil.formatColorToParsableHexString( + theming.get(CAPABILITIES_THEMING_COLOR_TEXT).getAsString() + ))); + } catch (Exception e) { + e.printStackTrace(); } } - return response; } - private boolean hasDirectEditingCapability(final JsonObject capabilities) { - if (capabilities.has(CAPABILITIES_FILES)) { - final var files = capabilities.getAsJsonObject(CAPABILITIES_FILES); - if (files.has(CAPABILITIES_FILES_DIRECT_EDITING)) { - final var directEditing = files.getAsJsonObject(CAPABILITIES_FILES_DIRECT_EDITING); - if (directEditing.has(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID)) { - return directEditing.get(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID).getAsBoolean(); - } + private void deserializeUserStatus(final JsonObject capabilities, final Capabilities response) { + if (!capabilities.has(CAPABILITIES_USER_STATUS)) { + return; + } + + final var userStatus = capabilities.getAsJsonObject(CAPABILITIES_USER_STATUS); + if (userStatus.has(CAPABILITIES_SUPPORTS_BUSY)) { + final var userStatusSupportsBusy = userStatus.getAsJsonPrimitive(CAPABILITIES_SUPPORTS_BUSY); + if (userStatusSupportsBusy != null) { + response.setUserStatusSupportsBusy(userStatusSupportsBusy.getAsBoolean()); + } + } + } + + private void deserializeDirectEditing(final JsonObject capabilities, final Capabilities response) { + if (!capabilities.has(CAPABILITIES_FILES)) { + response.setDirectEditingAvailable(false); + return; + } + + final var files = capabilities.getAsJsonObject(CAPABILITIES_FILES); + if (files.has(CAPABILITIES_FILES_DIRECT_EDITING)) { + final var directEditing = files.getAsJsonObject(CAPABILITIES_FILES_DIRECT_EDITING); + if (directEditing.has(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID)) { + response.setDirectEditingAvailable( + directEditing.get(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID).getAsBoolean() + ); } } - return false; } } From e54041936494972c9aa1c8c67913ad7bbedb88db Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 27 Apr 2026 12:07:22 -0400 Subject: [PATCH 2/2] test(Capabilities): improve/expand deserializer tests Signed-off-by: Josh --- .../sync/CapabilitiesDeserializerTest.java | 141 ++++++++++++++++-- 1 file changed, 130 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializerTest.java b/app/src/test/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializerTest.java index 69b28290e..fce2eb1b3 100644 --- a/app/src/test/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializerTest.java +++ b/app/src/test/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializerTest.java @@ -14,6 +14,7 @@ import android.graphics.Color; import com.google.gson.JsonParser; +import com.owncloud.android.lib.resources.shares.OCShare; import org.junit.Test; import org.junit.runner.RunWith; @@ -117,7 +118,6 @@ public void testDefaultWithInvalidApiVersion() { assertEquals(Color.parseColor("#ffffff"), capabilities.getTextColor()); } - @Test public void testRealisticSample() { //language=json @@ -314,7 +314,6 @@ public void testRealisticSample() { assertFalse("Wrongly reporting that direct editing is supported", capabilities.isDirectEditingAvailable()); } - @Test public void testDirectEditing() { //language=json @@ -364,12 +363,10 @@ public void testDirectEditing() { "}"; final var capabilities1 = deserializer.deserialize(JsonParser.parseString(responseNotOk), null, null); assertFalse("Wrongly reporting that direct editing is supported", capabilities1.isDirectEditingAvailable()); - } - @Test - public void testSharingDisabled() { + public void testPublicSharingDisabledWithoutPassword() { //language=json final String response = "" + "{" + @@ -382,10 +379,6 @@ public void testSharingDisabled() { " \"extendedSupport\": false" + " }," + " \"capabilities\": {" + - " \"core\": {" + - " \"pollinterval\": 60," + - " \"webdav-root\": \"remote.php/webdav\"" + - " }," + " \"notes\": {" + " \"api_version\": [" + " \"0.2\"," + @@ -437,8 +430,134 @@ public void testSharingDisabled() { " }" + "}"; final var capabilities = deserializer.deserialize(JsonParser.parseString(response), null, null); - assertNull(capabilities.getETag()); assertEquals("[\"0.2\",\"1.1\"]", capabilities.getApiVersion()); - assertFalse("Wrongly reporting that direct editing is supported", capabilities.isDirectEditingAvailable()); + assertFalse(capabilities.getPublicPasswordEnforced()); + assertFalse(capabilities.getAskForOptionalPassword()); + assertFalse(capabilities.isReSharingAllowed()); + assertEquals(31, capabilities.getDefaultPermission()); + assertTrue(capabilities.getFederationShare()); + } + + @Test + public void testShareApiDisabledKeepsDefaults() { + //language=json + final String response = "" + + "{" + + " \"version\": {" + + " \"major\": 31," + + " \"minor\": 0," + + " \"micro\": 8" + + " }," + + " \"capabilities\": {" + + " \"notes\": {" + + " \"api_version\": [" + + " \"0.2\"," + + " \"1.1\"" + + " ]" + + " }," + + " \"files_sharing\": {" + + " \"api_enabled\": false," + + " \"public\": {" + + " \"enabled\": false" + + " }," + + " \"user\": {" + + " \"send_mail\": false" + + " }," + + " \"resharing\": false," + + " \"federation\": {" + + " \"outgoing\": true," + + " \"incoming\": true," + + " \"expire_date\": {" + + " \"enabled\": true" + + " }" + + " }" + + " }" + + " }" + + "}"; + final var capabilities = deserializer.deserialize(JsonParser.parseString(response), null, null); + assertEquals("[\"0.2\",\"1.1\"]", capabilities.getApiVersion()); + assertTrue(capabilities.getFederationShare()); + assertFalse(capabilities.getPublicPasswordEnforced()); + assertFalse(capabilities.getAskForOptionalPassword()); + assertFalse(capabilities.isReSharingAllowed()); + assertEquals(OCShare.NO_PERMISSION, capabilities.getDefaultPermission()); + } + + @Test + public void testMissingDefaultPermissionsKeepsDefault() { + //language=json + final String response = "" + + "{" + + " \"version\": {" + + " \"major\": 33," + + " \"minor\": 0," + + " \"micro\": 2" + + " }," + + " \"capabilities\": {" + + " \"notes\": {" + + " \"api_version\": [" + + " \"0.2\"," + + " \"1.1\"" + + " ]" + + " }," + + " \"files_sharing\": {" + + " \"api_enabled\": true," + + " \"public\": {" + + " \"enabled\": false" + + " }," + + " \"resharing\": false," + + " \"federation\": {" + + " \"outgoing\": true," + + " \"incoming\": true," + + " \"expire_date\": {" + + " \"enabled\": true" + + " }" + + " }" + + " }" + + " }" + + "}"; + final var capabilities = deserializer.deserialize(JsonParser.parseString(response), null, null); + assertEquals("[\"0.2\",\"1.1\"]", capabilities.getApiVersion()); + assertTrue(capabilities.getFederationShare()); + assertFalse(capabilities.isReSharingAllowed()); + assertEquals(OCShare.NO_PERMISSION, capabilities.getDefaultPermission()); + } + + @Test + public void testMissingFederationKeepsDefault() { + //language=json + final String response = "" + + "{" + + " \"version\": {" + + " \"major\": 33," + + " \"minor\": 0," + + " \"micro\": 2" + + " }," + + " \"capabilities\": {" + + " \"notes\": {" + + " \"api_version\": [" + + " \"0.2\"," + + " \"1.1\"" + + " ]" + + " }," + + " \"files_sharing\": {" + + " \"api_enabled\": true," + + " \"public\": {" + + " \"enabled\": true," + + " \"password\": {" + + " \"enforced\": false," + + " \"askForOptionalPassword\": false" + + " }" + + " }," + + " \"resharing\": true," + + " \"default_permissions\": 31" + + " }" + + " }" + + "}"; + final var capabilities = deserializer.deserialize(JsonParser.parseString(response), null, null); + assertEquals("[\"0.2\",\"1.1\"]", capabilities.getApiVersion()); + assertFalse(capabilities.getFederationShare()); + assertTrue(capabilities.isReSharingAllowed()); + assertEquals(31, capabilities.getDefaultPermission()); } }