From a8e648f68c5ed3e762663b8182e03e3410e709da Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 30 Jun 2026 16:51:40 -0600 Subject: [PATCH] Reject fractional integer sliders Make integer slider eligibility reject fractional bounds, current values, or options before choosing the slider UI. Those forms now use the numeric text input instead of relying on slider rounding. --- .../conversations/entities/Conversation.java | 29 +++++++++++--- .../entities/ConversationTest.java | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index 3a7488a75aa21524980580d0d168f21610ba2d33..606e6f039d3cd2b9d167235cc594ff5460686e90 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -1875,21 +1875,26 @@ public class Conversation extends AbstractEntity if (!isNumericDatatype(datatype)) return null; final Element range = validate.findChild("range", "http://jabber.org/protocol/xdata-validate"); - final Float min = rangeFloat(range, "min"); - final Float max = rangeFloat(range, "max"); + final String minValue = range == null ? null : range.getAttribute("min"); + final String maxValue = range == null ? null : range.getAttribute("max"); + final boolean integerDatatype = isIntegerDatatype(datatype); + if (integerDatatype && (!isIntegerLexicalValue(minValue) || !isIntegerLexicalValue(maxValue))) return null; + final Float min = parseFloat(minValue); + final Float max = parseFloat(maxValue); if (min == null || max == null || min >= max) return null; final String value = firstValue(field); if (value == null || value.equals("")) return null; + if (integerDatatype && !isIntegerLexicalValue(value)) return null; final Float parsedValue = parseFloat(value); if (parsedValue == null || parsedValue < min || parsedValue > max) return null; - final List options = optionValues(field); + final List options = optionValues(field, integerDatatype); if (options == null) return null; final Float step; if (options.size() < 2) { - step = isIntegerDatatype(datatype) ? 1f : 0f; + step = integerDatatype ? 1f : 0f; } else { step = uniformStep(options); if (step == null) return null; @@ -1923,10 +1928,12 @@ public class Conversation extends AbstractEntity return step; } - private static List optionValues(final Element field) { + private static List optionValues(final Element field, final boolean integerDatatype) { final List values = new ArrayList<>(); for (final Option option : Option.forField(field)) { - final Float value = parseFloat(option.getValue()); + final String optionValue = option.getValue(); + if (integerDatatype && !isIntegerLexicalValue(optionValue)) return null; + final Float value = parseFloat(optionValue); if (value == null) return null; values.add(value); } @@ -1961,6 +1968,16 @@ public class Conversation extends AbstractEntity return Math.abs(Math.rint(multiple) - multiple) < 0.0001f; } + private static boolean isIntegerLexicalValue(final String value) { + if (value == null || value.equals("")) return false; + final int start = value.charAt(0) == '+' || value.charAt(0) == '-' ? 1 : 0; + if (start == value.length()) return false; + for (int i = start; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) return false; + } + return true; + } + private static boolean isIntegerDatatype(final String datatype) { return "xs:integer".equals(datatype) || "xs:int".equals(datatype) diff --git a/src/test/java/eu/siacs/conversations/entities/ConversationTest.java b/src/test/java/eu/siacs/conversations/entities/ConversationTest.java index c15a7a0ea190e2b42a669ed1d3b176d7db213a13..5f8b86169067f01926d90b99c819c290baa5c9ea 100644 --- a/src/test/java/eu/siacs/conversations/entities/ConversationTest.java +++ b/src/test/java/eu/siacs/conversations/entities/ConversationTest.java @@ -220,6 +220,27 @@ public class ConversationTest { Assert.assertNull(Conversation.steppedSliderStep(malformedMax)); } + @Test + public void steppedSliderStepRejectsIntegerValueThatCannotLandOnStep() { + final var field = sliderField("xs:integer", "0", "10", "0.5"); + + Assert.assertNull(Conversation.steppedSliderStep(field)); + } + + @Test + public void steppedSliderStepRejectsFractionalIntegerBounds() { + final var field = sliderField("xs:integer", "0.5", "10.5", "1.5"); + + Assert.assertNull(Conversation.steppedSliderStep(field)); + } + + @Test + public void steppedSliderStepRejectsFractionalIntegerOptions() { + final var field = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1"); + + Assert.assertNull(Conversation.steppedSliderStep(field)); + } + @Test public void formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() { Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long")); @@ -310,6 +331,23 @@ public class ConversationTest { } } + @Test + public void rangedIntegerFieldWithFractionalValuesFallsBackToTextInput() { + final var session = withOccupantId.pagerAdapter.new CommandSession( + "test", "node", mock(XmppConnectionService.class)); + try { + session.responseElement = new Element("x", Namespace.DATA); + session.responseElement.setAttribute("type", "form"); + final var fractionalBounds = sliderField("xs:integer", "0.5", "10.5", "1.5"); + final var fractionalOptions = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1"); + + Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalBounds).viewType); + Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalOptions).viewType); + } finally { + session.loadingTimer.cancel(); + } + } + @Test public void sliderFieldViewHolderRebindsDifferentStepSizesWithoutCrashing() { final var context = new ContextThemeWrapper(