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 steppedSliderStepRejectsInRangeValueMissingFromOptionLattice() {
186        final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
187
188        Assert.assertNull(
189                "A value the option-derived slider cannot represent should fall back to text input",
190                Conversation.steppedSliderStep(field));
191    }
192
193    @Test
194    public void steppedSliderStepAcceptsCompatibleOptionLattice() {
195        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
196
197        Assert.assertEquals(Float.valueOf(35f), Conversation.steppedSliderStep(field));
198    }
199
200    @Test
201    public void steppedSliderStepUsesIntegerStepWithoutOptions() {
202        final var field = sliderField("xs:integer", "0", "10", "7");
203
204        Assert.assertEquals(Float.valueOf(1f), Conversation.steppedSliderStep(field));
205    }
206
207    @Test
208    public void steppedSliderStepUsesContinuousStepWithoutOptionsForDecimal() {
209        final var field = sliderField("xs:decimal", "0", "1", "0.000001");
210
211        Assert.assertEquals(Float.valueOf(0f), Conversation.steppedSliderStep(field));
212    }
213
214    @Test
215    public void steppedSliderStepRejectsMissingOrMalformedRangeBounds() {
216        final var missingMin = sliderField("xs:integer", null, "10", "5");
217        final var malformedMax = sliderField("xs:integer", "0", "not-a-number", "5");
218
219        Assert.assertNull(Conversation.steppedSliderStep(missingMin));
220        Assert.assertNull(Conversation.steppedSliderStep(malformedMax));
221    }
222
223    @Test
224    public void steppedSliderStepRejectsIntegerValueThatCannotLandOnStep() {
225        final var field = sliderField("xs:integer", "0", "10", "0.5");
226
227        Assert.assertNull(Conversation.steppedSliderStep(field));
228    }
229
230    @Test
231    public void steppedSliderStepRejectsFractionalIntegerBounds() {
232        final var field = sliderField("xs:integer", "0.5", "10.5", "1.5");
233
234        Assert.assertNull(Conversation.steppedSliderStep(field));
235    }
236
237    @Test
238    public void steppedSliderStepRejectsFractionalIntegerOptions() {
239        final var field = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1");
240
241        Assert.assertNull(Conversation.steppedSliderStep(field));
242    }
243
244    @Test
245    public void steppedSliderStepRejectsFractionalIntegerValueAtFloatPrecisionBoundary() {
246        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216.5");
247
248        Assert.assertNull(Conversation.steppedSliderStep(field));
249    }
250
251    @Test
252    public void steppedSliderStepRejectsIntegerValueRoundedByFloatParsing() {
253        final var field = sliderField("xs:integer", "16777216", "16777218", "16777217");
254
255        Assert.assertNull(Conversation.steppedSliderStep(field));
256    }
257
258    @Test
259    public void steppedSliderStepRejectsIntegerRangeWithUnrepresentableIntermediateValue() {
260        final var field = sliderField("xs:integer", "16777216", "16777218", "16777216");
261
262        Assert.assertNull(Conversation.steppedSliderStep(field));
263    }
264
265    @Test
266    public void formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
267        Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));
268    }
269
270    @Test
271    public void formatSliderValueUsesPlainDecimalLexicalFormForDecimalDatatype() {
272        Assert.assertEquals("0.000001", Conversation.formatSliderValue(0.000001f, "xs:decimal"));
273    }
274
275    @Test
276    public void steppedSliderStepRejectsIncompatibleBounds() {
277        final var field = sliderField("xs:integer", "0", "70", "34", "0", "34", "68");
278
279        Assert.assertNull(Conversation.steppedSliderStep(field));
280    }
281
282    @Test
283    public void steppedSliderStepRejectsMalformedOptions() {
284        final var field = sliderField("xs:integer", "0", "70", "35", "0", "not-a-number", "70");
285
286        Assert.assertNull(Conversation.steppedSliderStep(field));
287    }
288
289    @Test
290    public void steppedSliderStepRejectsValuesOutsideRange() {
291        final var below = sliderField("xs:integer", "0", "70", "-5", "0", "35", "70");
292        final var above = sliderField("xs:integer", "0", "70", "999", "0", "35", "70");
293
294        Assert.assertNull(Conversation.steppedSliderStep(below));
295        Assert.assertNull(Conversation.steppedSliderStep(above));
296    }
297
298    @Test
299    public void steppedSliderStepRejectsDuplicateOptions() {
300        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "35", "70");
301
302        Assert.assertNull(Conversation.steppedSliderStep(field));
303    }
304
305    @Test
306    public void rangedListSingleWithCustomValueFallsBackToTextInputWhenSliderCannotRepresentIt() {
307        final var session = withOccupantId.pagerAdapter.new CommandSession(
308                "test", "node", mock(XmppConnectionService.class));
309        try {
310            session.responseElement = new Element("x", Namespace.DATA);
311            session.responseElement.setAttribute("type", "form");
312            final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
313            field.setAttribute("type", "list-single");
314
315            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(field).viewType);
316        } finally {
317            session.loadingTimer.cancel();
318        }
319    }
320
321    @Test
322    public void rangedNumericFieldWithBadBoundsFallsBackToTextInput() {
323        final var session = withOccupantId.pagerAdapter.new CommandSession(
324                "test", "node", mock(XmppConnectionService.class));
325        try {
326            session.responseElement = new Element("x", Namespace.DATA);
327            session.responseElement.setAttribute("type", "form");
328            final var missingMin = sliderField("xs:integer", null, "10", "5");
329            final var malformedMax = sliderField("xs:integer", "0", "not-a-number", "5");
330
331            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingMin).viewType);
332            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(malformedMax).viewType);
333        } finally {
334            session.loadingTimer.cancel();
335        }
336    }
337
338    @Test
339    public void rangedNumericFieldWithEmptyValueFallsBackToTextInput() {
340        final var session = withOccupantId.pagerAdapter.new CommandSession(
341                "test", "node", mock(XmppConnectionService.class));
342        try {
343            session.responseElement = new Element("x", Namespace.DATA);
344            session.responseElement.setAttribute("type", "form");
345            final var emptyValue = sliderField("xs:integer", "0", "70", "", "0", "35", "70");
346            final var missingValue = sliderField("xs:integer", "0", "70", null, "0", "35", "70");
347
348            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(emptyValue).viewType);
349            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingValue).viewType);
350        } finally {
351            session.loadingTimer.cancel();
352        }
353    }
354
355    @Test
356    public void rangedIntegerFieldWithFractionalValuesFallsBackToTextInput() {
357        final var session = withOccupantId.pagerAdapter.new CommandSession(
358                "test", "node", mock(XmppConnectionService.class));
359        try {
360            session.responseElement = new Element("x", Namespace.DATA);
361            session.responseElement.setAttribute("type", "form");
362            final var fractionalBounds = sliderField("xs:integer", "0.5", "10.5", "1.5");
363            final var fractionalOptions = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1");
364
365            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalBounds).viewType);
366            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalOptions).viewType);
367        } finally {
368            session.loadingTimer.cancel();
369        }
370    }
371
372    @Test
373    public void sliderFieldViewHolderRebindsDifferentStepSizesWithoutCrashing() {
374        final var context = new ContextThemeWrapper(
375                RuntimeEnvironment.getApplication(),
376                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
377        final var session = withOccupantId.pagerAdapter.new CommandSession(
378                "test", "node", mock(XmppConnectionService.class));
379        try {
380            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
381            final var holder = session.new SliderFieldViewHolder(binding);
382            final var steppedField = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
383            holder.bind(session.new Field(
384                    eu.siacs.conversations.xmpp.forms.Field.parse(steppedField),
385                    session.TYPE_SLIDER_FIELD));
386
387            final var integerField = sliderField("xs:integer", "0", "10", "7");
388            holder.bind(session.new Field(
389                    eu.siacs.conversations.xmpp.forms.Field.parse(integerField),
390                    session.TYPE_SLIDER_FIELD));
391
392            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
393            Assert.assertEquals(10f, binding.slider.getValueTo(), 0.0001f);
394            Assert.assertEquals(7f, binding.slider.getValue(), 0.0001f);
395            Assert.assertEquals(1f, binding.slider.getStepSize(), 0.0001f);
396        } finally {
397            session.loadingTimer.cancel();
398        }
399    }
400
401    @Test
402    public void sliderFieldViewHolderRebindsFromLargeDiscreteToSmallDiscreteRangeWithoutCrashing() {
403        final var context = new ContextThemeWrapper(
404                RuntimeEnvironment.getApplication(),
405                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
406        final var session = withOccupantId.pagerAdapter.new CommandSession(
407                "test", "node", mock(XmppConnectionService.class));
408        try {
409            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
410            final var holder = session.new SliderFieldViewHolder(binding);
411            final var discreteField = sliderField("xs:integer", "0", "100", "50", "0", "10", "20");
412            holder.bind(session.new Field(
413                    eu.siacs.conversations.xmpp.forms.Field.parse(discreteField),
414                    session.TYPE_SLIDER_FIELD));
415
416            final var continuousField = sliderField(
417                    "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");
418            holder.bind(session.new Field(
419                    eu.siacs.conversations.xmpp.forms.Field.parse(continuousField),
420                    session.TYPE_SLIDER_FIELD));
421
422            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
423            Assert.assertEquals(1f, binding.slider.getValueTo(), 0.0001f);
424            Assert.assertEquals(0.5f, binding.slider.getValue(), 0.0001f);
425            Assert.assertEquals(0.1f, binding.slider.getStepSize(), 0.0001f);
426            binding.slider.measure(
427                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
428                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY));
429            binding.slider.layout(0, 0, 100, 100);
430            binding.slider.draw(new Canvas(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)));
431        } finally {
432            session.loadingTimer.cancel();
433        }
434    }
435
436    private static Element sliderField(
437            final String datatype,
438            final String min,
439            final String max,
440            final String value,
441            final String... options) {
442        final var field = new Element("field", Namespace.DATA);
443        field.setAttribute("type", "text-single");
444        final var validate = field.addChild("validate", "http://jabber.org/protocol/xdata-validate");
445        validate.setAttribute("datatype", datatype);
446        final var range = validate.addChild("range", "http://jabber.org/protocol/xdata-validate");
447        range.setAttribute("min", min);
448        range.setAttribute("max", max);
449        if (value != null) {
450            field.addChild("value", Namespace.DATA).setContent(value);
451        }
452        for (final String option : options) {
453            field.addChild("option", Namespace.DATA)
454                    .addChild("value", Namespace.DATA)
455                    .setContent(option);
456        }
457        return field;
458    }
459}