ConversationTest.java

  1package eu.siacs.conversations.entities;
  2
  3import static org.mockito.ArgumentMatchers.any;
  4import static org.mockito.Mockito.doAnswer;
  5import static org.mockito.Mockito.mock;
  6import static org.mockito.Mockito.when;
  7
  8import java.util.concurrent.atomic.AtomicReference;
  9
 10import org.junit.Before;
 11import org.junit.BeforeClass;
 12import org.junit.Test;
 13import org.junit.runner.RunWith;
 14import org.robolectric.RobolectricTestRunner;
 15import org.robolectric.RuntimeEnvironment;
 16import org.robolectric.Shadows;
 17import org.robolectric.annotation.Config;
 18import org.robolectric.annotation.ConscryptMode;
 19
 20import android.graphics.Bitmap;
 21import android.graphics.Canvas;
 22import android.os.Build;
 23import android.os.Looper;
 24import android.view.ContextThemeWrapper;
 25import android.view.LayoutInflater;
 26import android.view.View;
 27import android.widget.ListView;
 28import android.widget.RelativeLayout;
 29import androidx.viewpager.widget.ViewPager;
 30import com.google.android.material.tabs.TabLayout;
 31import eu.siacs.conversations.Conversations;
 32import eu.siacs.conversations.R;
 33import eu.siacs.conversations.databinding.CommandSliderFieldBinding;
 34import eu.siacs.conversations.services.XmppConnectionService;
 35import eu.siacs.conversations.xml.Element;
 36import eu.siacs.conversations.xml.Namespace;
 37import eu.siacs.conversations.xmpp.Jid;
 38import im.conversations.android.xmpp.model.disco.info.Feature;
 39import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 40import junit.framework.Assert;
 41
 42@RunWith(RobolectricTestRunner.class)
 43@Config(sdk = Build.VERSION_CODES.TIRAMISU, application = Conversations.class)
 44@ConscryptMode(ConscryptMode.Mode.OFF)
 45public class ConversationTest {
 46    private static final InfoQuery INFO_QUERY_WITH_OCCUPANT_ID = new InfoQuery();
 47    private static final InfoQuery INFO_QUERY_WITHOUT_OCCUPANT_ID = new InfoQuery();
 48
 49    private Conversation withOccupantId;
 50    private Conversation withoutOccupantId;
 51    private Conversation nullMucOptions;
 52
 53    @BeforeClass
 54    public static void setupClass() {
 55        final var occupantIdFeature = new Feature();
 56        occupantIdFeature.setVar(Namespace.OCCUPANT_ID);
 57        INFO_QUERY_WITH_OCCUPANT_ID.addChild(occupantIdFeature);
 58    }
 59
 60    @Before
 61    public void setUp() throws Exception {
 62        final var account = mock(Account.class);
 63        when(account.getJid()).thenReturn(Jid.ofLocalAndDomain("testAccount", "example.org"));
 64
 65        withOccupantId = new Conversation(
 66            "Test MUC",
 67            account,
 68            Jid.ofLocalAndDomain("testMuc", "example.org"),
 69            Conversation.MODE_MULTI
 70        );
 71        withOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITH_OCCUPANT_ID);
 72
 73        withoutOccupantId = new Conversation(
 74            "Test MUC",
 75            account,
 76            Jid.ofLocalAndDomain("testMuc", "example.org"),
 77            Conversation.MODE_MULTI
 78        );
 79        withoutOccupantId.getMucOptions().updateConfiguration(INFO_QUERY_WITHOUT_OCCUPANT_ID);
 80
 81        nullMucOptions = new Conversation(
 82            "Test MUC",
 83            account,
 84            Jid.ofLocalAndDomain("testMuc", "example.org"),
 85            Conversation.MODE_MULTI
 86        );
 87        final var mucOptionsField = Conversation.class.getDeclaredField("mucOptions");
 88        mucOptionsField.setAccessible(true);
 89        ((AtomicReference<?>) mucOptionsField.get(nullMucOptions)).set(null);
 90    }
 91
 92    @Test
 93    public void getMucOccupantsCacheReturnsCacheWhenMucOptionsIsNull() throws Exception {
 94        var cache = nullMucOptions.getMucOccupantCache();
 95        Assert.assertNotNull("Should return cache when mucOptions is null", cache);
 96    }
 97
 98    @Test
 99    public void getMucOccupantsCacheReturnsCacheWhenFeatureSupported() throws Exception {
100        var cache = withOccupantId.getMucOccupantCache();
101        Assert.assertNotNull("Should return cache when occupant-id is supported", cache);
102    }
103
104    @Test
105    public void getMucOccupantsCacheReturnsCacheWhenFeatureNotSupported() throws Exception {
106        var cache = withoutOccupantId.getMucOccupantCache();
107        Assert.assertNotNull("Should return cache even when occupant-id is not supported", cache);
108    }
109
110    @Test
111    public void setupViewPagerListenerDoesNotLeakBetweenConversations() {
112        final var context = RuntimeEnvironment.getApplication();
113        final var pager = new ViewPager(context);
114        final var tabs = mock(TabLayout.class);
115
116        final int[] selectedTabPosition = {0};
117        doAnswer(invocation -> {
118            ViewPager vp = invocation.getArgument(0);
119            vp.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
120                public void onPageScrollStateChanged(int state) {}
121                public void onPageScrolled(int p, float o, int opx) {}
122                public void onPageSelected(int position) {
123                    selectedTabPosition[0] = position;
124                }
125            });
126            return null;
127        }).when(tabs).setupWithViewPager(any(ViewPager.class));
128        when(tabs.getSelectedTabPosition()).thenAnswer(inv -> selectedTabPosition[0]);
129
130        final var page1 = new RelativeLayout(context);
131        final var page2 = new RelativeLayout(context);
132        final var commandsView = new ListView(context);
133        commandsView.setId(R.id.commands_view);
134        page2.addView(commandsView);
135        pager.addView(page1);
136        pager.addView(page2);
137
138        final var account = mock(Account.class);
139        when(account.getJid()).thenReturn(Jid.ofLocalAndDomain("testAccount", "example.org"));
140        final var roster = mock(Roster.class);
141        when(account.getRoster()).thenReturn(roster);
142
143        final var mucJid = Jid.ofLocalAndDomain("operations", "conference.soprani.ca");
144        final var mucContact = mock(Contact.class);
145        when(mucContact.isApp()).thenReturn(false);
146        when(mucContact.getJid()).thenReturn(mucJid);
147        when(roster.getContact(mucJid)).thenReturn(mucContact);
148
149        final var appJid = Jid.ofDomain("jmp.chat");
150        final var appContact = mock(Contact.class);
151        when(appContact.isApp()).thenReturn(true);
152        when(appContact.getJid()).thenReturn(appJid);
153        when(roster.getContact(appJid)).thenReturn(appContact);
154
155        final var muc = new Conversation("MUC", account, mucJid, Conversation.MODE_MULTI);
156        final var app = new Conversation("App", account, appJid, Conversation.MODE_SINGLE);
157
158        muc.setupViewPager(pager, tabs, false, null);
159        muc.showViewPager();
160
161        pager.layout(0, 0, 1024, 768);
162        Shadows.shadowOf(Looper.getMainLooper()).idle();
163
164        Assert.assertEquals("MUC should start on Conversation tab", 0, muc.getCurrentTab());
165
166        app.setupViewPager(pager, tabs, false, muc);
167        app.showViewPager();
168
169        Shadows.shadowOf(Looper.getMainLooper()).idle();
170
171        pager.setCurrentItem(1);
172
173        Assert.assertEquals(
174            "Page change on app conversation must not corrupt MUC's tab state",
175            0,
176            muc.getCurrentTab());
177
178        Assert.assertEquals(
179            "Tab indicator must stay in sync with the selected page",
180            1,
181            tabs.getSelectedTabPosition());
182    }
183
184    @Test
185    public void sliderSpecRejectsInRangeValueMissingFromOptionLattice() {
186        final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
187
188        assertNotSlider("A value the option-derived slider cannot represent should fall back to text input", field);
189    }
190
191    @Test
192    public void sliderSpecAcceptsCompatibleOptionLattice() {
193        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
194
195        assertSliderStep(field, 35f);
196    }
197
198    @Test
199    public void sliderSpecUsesIntegerStepWithoutOptions() {
200        final var field = sliderField("xs:integer", "0", "10", "7");
201
202        assertSliderStep(field, 1f);
203    }
204
205    @Test
206    public void sliderSpecUsesContinuousStepWithoutOptionsForDecimal() {
207        final var field = sliderField("xs:decimal", "0", "1", "0.000001");
208
209        assertSliderStep(field, 0f);
210    }
211
212    @Test
213    public void sliderSpecRejectsMissingOrMalformedRangeBounds() {
214        final var missingMin = sliderField("xs:integer", null, "10", "5");
215        final var malformedMax = sliderField("xs:integer", "0", "not-a-number", "5");
216
217        assertNotSlider(missingMin);
218        assertNotSlider(malformedMax);
219    }
220
221    @Test
222    public void sliderSpecRejectsIntegerValueThatCannotLandOnStep() {
223        final var field = sliderField("xs:integer", "0", "10", "0.5");
224
225        assertNotSlider(field);
226    }
227
228    @Test
229    public void sliderSpecRejectsFractionalIntegerBounds() {
230        final var field = sliderField("xs:integer", "0.5", "10.5", "1.5");
231
232        assertNotSlider(field);
233    }
234
235    @Test
236    public void sliderSpecRejectsFractionalIntegerOptions() {
237        final var field = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1");
238
239        assertNotSlider(field);
240    }
241
242    @Test
243    public void sliderSpecRejectsFractionalIntegerValueAtFloatPrecisionBoundary() {
244        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216.5");
245
246        assertNotSlider(field);
247    }
248
249    @Test
250    public void sliderSpecRejectsIntegerValueRoundedByFloatParsing() {
251        final var field = sliderField("xs:integer", "16777216", "16777218", "16777217");
252
253        assertNotSlider(field);
254    }
255
256    @Test
257    public void sliderSpecRejectsIntegerRangeWithUnrepresentableIntermediateValue() {
258        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216");
259
260        assertNotSlider(field);
261    }
262
263    @Test
264    public void formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
265        Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));
266    }
267
268    @Test
269    public void formatSliderValueUsesPlainDecimalLexicalFormForDecimalDatatype() {
270        Assert.assertEquals("0.000001", Conversation.formatSliderValue(0.000001f, "xs:decimal"));
271    }
272
273    @Test
274    public void sliderSpecRejectsIncompatibleBounds() {
275        final var field = sliderField("xs:integer", "0", "70", "34", "0", "34", "68");
276
277        assertNotSlider(field);
278    }
279
280    @Test
281    public void sliderSpecRejectsMalformedOptions() {
282        final var field = sliderField("xs:integer", "0", "70", "35", "0", "not-a-number", "70");
283
284        assertNotSlider(field);
285    }
286
287    @Test
288    public void sliderSpecRejectsValuesOutsideRange() {
289        final var below = sliderField("xs:integer", "0", "70", "-5", "0", "35", "70");
290        final var above = sliderField("xs:integer", "0", "70", "999", "0", "35", "70");
291
292        assertNotSlider(below);
293        assertNotSlider(above);
294    }
295
296    @Test
297    public void sliderSpecRejectsDuplicateOptions() {
298        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "35", "70");
299
300        assertNotSlider(field);
301    }
302
303    @Test
304    public void rangedListSingleWithCustomValueFallsBackToTextInputWhenSliderCannotRepresentIt() {
305        final var session = withOccupantId.pagerAdapter.new CommandSession(
306                "test", "node", mock(XmppConnectionService.class));
307        try {
308            session.responseElement = new Element("x", Namespace.DATA);
309            session.responseElement.setAttribute("type", "form");
310            final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
311            field.setAttribute("type", "list-single");
312
313            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(field).viewType);
314        } finally {
315            session.loadingTimer.cancel();
316        }
317    }
318
319    @Test
320    public void rangedNumericFieldWithBadBoundsFallsBackToTextInput() {
321        final var session = withOccupantId.pagerAdapter.new CommandSession(
322                "test", "node", mock(XmppConnectionService.class));
323        try {
324            session.responseElement = new Element("x", Namespace.DATA);
325            session.responseElement.setAttribute("type", "form");
326            final var missingMin = sliderField("xs:integer", null, "10", "5");
327            final var malformedMax = sliderField("xs:integer", "0", "not-a-number", "5");
328
329            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingMin).viewType);
330            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(malformedMax).viewType);
331        } finally {
332            session.loadingTimer.cancel();
333        }
334    }
335
336    @Test
337    public void rangedNumericFieldWithEmptyValueFallsBackToTextInput() {
338        final var session = withOccupantId.pagerAdapter.new CommandSession(
339                "test", "node", mock(XmppConnectionService.class));
340        try {
341            session.responseElement = new Element("x", Namespace.DATA);
342            session.responseElement.setAttribute("type", "form");
343            final var emptyValue = sliderField("xs:integer", "0", "70", "", "0", "35", "70");
344            final var missingValue = sliderField("xs:integer", "0", "70", null, "0", "35", "70");
345
346            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(emptyValue).viewType);
347            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingValue).viewType);
348        } finally {
349            session.loadingTimer.cancel();
350        }
351    }
352
353    @Test
354    public void rangedIntegerFieldWithFractionalValuesFallsBackToTextInput() {
355        final var session = withOccupantId.pagerAdapter.new CommandSession(
356                "test", "node", mock(XmppConnectionService.class));
357        try {
358            session.responseElement = new Element("x", Namespace.DATA);
359            session.responseElement.setAttribute("type", "form");
360            final var fractionalBounds = sliderField("xs:integer", "0.5", "10.5", "1.5");
361            final var fractionalOptions = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1");
362
363            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalBounds).viewType);
364            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalOptions).viewType);
365        } finally {
366            session.loadingTimer.cancel();
367        }
368    }
369
370    @Test
371    public void sliderFieldViewHolderRebindsDifferentStepSizesWithoutCrashing() {
372        final var context = new ContextThemeWrapper(
373                RuntimeEnvironment.getApplication(),
374                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
375        final var session = withOccupantId.pagerAdapter.new CommandSession(
376                "test", "node", mock(XmppConnectionService.class));
377        try {
378            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
379            final var holder = session.new SliderFieldViewHolder(binding);
380            final var steppedField = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
381            holder.bind(session.new Field(
382                    eu.siacs.conversations.xmpp.forms.Field.parse(steppedField),
383                    session.TYPE_SLIDER_FIELD));
384
385            final var integerField = sliderField("xs:integer", "0", "10", "7");
386            holder.bind(session.new Field(
387                    eu.siacs.conversations.xmpp.forms.Field.parse(integerField),
388                    session.TYPE_SLIDER_FIELD));
389
390            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
391            Assert.assertEquals(10f, binding.slider.getValueTo(), 0.0001f);
392            Assert.assertEquals(7f, binding.slider.getValue(), 0.0001f);
393            Assert.assertEquals(1f, binding.slider.getStepSize(), 0.0001f);
394        } finally {
395            session.loadingTimer.cancel();
396        }
397    }
398
399    @Test
400    public void sliderFieldViewHolderRebindsFromLargeDiscreteToSmallDiscreteRangeWithoutCrashing() {
401        final var context = new ContextThemeWrapper(
402                RuntimeEnvironment.getApplication(),
403                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
404        final var session = withOccupantId.pagerAdapter.new CommandSession(
405                "test", "node", mock(XmppConnectionService.class));
406        try {
407            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
408            final var holder = session.new SliderFieldViewHolder(binding);
409            final var discreteField = sliderField("xs:integer", "0", "100", "50", "0", "10", "20");
410            holder.bind(session.new Field(
411                    eu.siacs.conversations.xmpp.forms.Field.parse(discreteField),
412                    session.TYPE_SLIDER_FIELD));
413
414            final var continuousField = sliderField(
415                    "xs:decimal", "0", "1", "0.5", "0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6", "0.7", "0.8", "0.9", "1");
416            holder.bind(session.new Field(
417                    eu.siacs.conversations.xmpp.forms.Field.parse(continuousField),
418                    session.TYPE_SLIDER_FIELD));
419
420            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
421            Assert.assertEquals(1f, binding.slider.getValueTo(), 0.0001f);
422            Assert.assertEquals(0.5f, binding.slider.getValue(), 0.0001f);
423            Assert.assertEquals(0.1f, binding.slider.getStepSize(), 0.0001f);
424            binding.slider.measure(
425                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
426                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY));
427            binding.slider.layout(0, 0, 100, 100);
428            binding.slider.draw(new Canvas(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)));
429        } finally {
430            session.loadingTimer.cancel();
431        }
432    }
433
434    private static void assertSliderStep(final Element field, final float step) {
435        final var spec = Conversation.sliderSpec(field);
436        Assert.assertNotNull("Field should be usable as a slider", spec);
437        Assert.assertEquals(step, spec.step(), 0.0001f);
438    }
439
440    private static void assertNotSlider(final Element field) {
441        Assert.assertNull(Conversation.sliderSpec(field));
442    }
443
444    private static void assertNotSlider(final String message, final Element field) {
445        Assert.assertNull(message, Conversation.sliderSpec(field));
446    }
447
448    private static Element sliderField(
449            final String datatype,
450            final String min,
451            final String max,
452            final String value,
453            final String... options) {
454        final var field = new Element("field", Namespace.DATA);
455        field.setAttribute("type", "text-single");
456        final var validate = field.addChild("validate", "http://jabber.org/protocol/xdata-validate");
457        validate.setAttribute("datatype", datatype);
458        final var range = validate.addChild("range", "http://jabber.org/protocol/xdata-validate");
459        range.setAttribute("min", min);
460        range.setAttribute("max", max);
461        if (value != null) {
462            field.addChild("value", Namespace.DATA).setContent(value);
463        }
464        for (final String option : options) {
465            field.addChild("option", Namespace.DATA)
466                    .addChild("value", Namespace.DATA)
467                    .setContent(option);
468        }
469        return field;
470    }
471}