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 formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
246        Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));
247    }
248
249    @Test
250    public void formatSliderValueUsesPlainDecimalLexicalFormForDecimalDatatype() {
251        Assert.assertEquals("0.000001", Conversation.formatSliderValue(0.000001f, "xs:decimal"));
252    }
253
254    @Test
255    public void steppedSliderStepRejectsIncompatibleBounds() {
256        final var field = sliderField("xs:integer", "0", "70", "34", "0", "34", "68");
257
258        Assert.assertNull(Conversation.steppedSliderStep(field));
259    }
260
261    @Test
262    public void steppedSliderStepRejectsMalformedOptions() {
263        final var field = sliderField("xs:integer", "0", "70", "35", "0", "not-a-number", "70");
264
265        Assert.assertNull(Conversation.steppedSliderStep(field));
266    }
267
268    @Test
269    public void steppedSliderStepRejectsValuesOutsideRange() {
270        final var below = sliderField("xs:integer", "0", "70", "-5", "0", "35", "70");
271        final var above = sliderField("xs:integer", "0", "70", "999", "0", "35", "70");
272
273        Assert.assertNull(Conversation.steppedSliderStep(below));
274        Assert.assertNull(Conversation.steppedSliderStep(above));
275    }
276
277    @Test
278    public void steppedSliderStepRejectsDuplicateOptions() {
279        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "35", "70");
280
281        Assert.assertNull(Conversation.steppedSliderStep(field));
282    }
283
284    @Test
285    public void rangedListSingleWithCustomValueFallsBackToTextInputWhenSliderCannotRepresentIt() {
286        final var session = withOccupantId.pagerAdapter.new CommandSession(
287                "test", "node", mock(XmppConnectionService.class));
288        try {
289            session.responseElement = new Element("x", Namespace.DATA);
290            session.responseElement.setAttribute("type", "form");
291            final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
292            field.setAttribute("type", "list-single");
293
294            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(field).viewType);
295        } finally {
296            session.loadingTimer.cancel();
297        }
298    }
299
300    @Test
301    public void rangedNumericFieldWithBadBoundsFallsBackToTextInput() {
302        final var session = withOccupantId.pagerAdapter.new CommandSession(
303                "test", "node", mock(XmppConnectionService.class));
304        try {
305            session.responseElement = new Element("x", Namespace.DATA);
306            session.responseElement.setAttribute("type", "form");
307            final var missingMin = sliderField("xs:integer", null, "10", "5");
308            final var malformedMax = sliderField("xs:integer", "0", "not-a-number", "5");
309
310            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingMin).viewType);
311            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(malformedMax).viewType);
312        } finally {
313            session.loadingTimer.cancel();
314        }
315    }
316
317    @Test
318    public void rangedNumericFieldWithEmptyValueFallsBackToTextInput() {
319        final var session = withOccupantId.pagerAdapter.new CommandSession(
320                "test", "node", mock(XmppConnectionService.class));
321        try {
322            session.responseElement = new Element("x", Namespace.DATA);
323            session.responseElement.setAttribute("type", "form");
324            final var emptyValue = sliderField("xs:integer", "0", "70", "", "0", "35", "70");
325            final var missingValue = sliderField("xs:integer", "0", "70", null, "0", "35", "70");
326
327            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(emptyValue).viewType);
328            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(missingValue).viewType);
329        } finally {
330            session.loadingTimer.cancel();
331        }
332    }
333
334    @Test
335    public void rangedIntegerFieldWithFractionalValuesFallsBackToTextInput() {
336        final var session = withOccupantId.pagerAdapter.new CommandSession(
337                "test", "node", mock(XmppConnectionService.class));
338        try {
339            session.responseElement = new Element("x", Namespace.DATA);
340            session.responseElement.setAttribute("type", "form");
341            final var fractionalBounds = sliderField("xs:integer", "0.5", "10.5", "1.5");
342            final var fractionalOptions = sliderField("xs:integer", "0", "1", "0", "0", "0.5", "1");
343
344            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalBounds).viewType);
345            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(fractionalOptions).viewType);
346        } finally {
347            session.loadingTimer.cancel();
348        }
349    }
350
351    @Test
352    public void sliderFieldViewHolderRebindsDifferentStepSizesWithoutCrashing() {
353        final var context = new ContextThemeWrapper(
354                RuntimeEnvironment.getApplication(),
355                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
356        final var session = withOccupantId.pagerAdapter.new CommandSession(
357                "test", "node", mock(XmppConnectionService.class));
358        try {
359            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
360            final var holder = session.new SliderFieldViewHolder(binding);
361            final var steppedField = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
362            holder.bind(session.new Field(
363                    eu.siacs.conversations.xmpp.forms.Field.parse(steppedField),
364                    session.TYPE_SLIDER_FIELD));
365
366            final var integerField = sliderField("xs:integer", "0", "10", "7");
367            holder.bind(session.new Field(
368                    eu.siacs.conversations.xmpp.forms.Field.parse(integerField),
369                    session.TYPE_SLIDER_FIELD));
370
371            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
372            Assert.assertEquals(10f, binding.slider.getValueTo(), 0.0001f);
373            Assert.assertEquals(7f, binding.slider.getValue(), 0.0001f);
374            Assert.assertEquals(1f, binding.slider.getStepSize(), 0.0001f);
375        } finally {
376            session.loadingTimer.cancel();
377        }
378    }
379
380    @Test
381    public void sliderFieldViewHolderRebindsFromLargeDiscreteToSmallDiscreteRangeWithoutCrashing() {
382        final var context = new ContextThemeWrapper(
383                RuntimeEnvironment.getApplication(),
384                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
385        final var session = withOccupantId.pagerAdapter.new CommandSession(
386                "test", "node", mock(XmppConnectionService.class));
387        try {
388            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
389            final var holder = session.new SliderFieldViewHolder(binding);
390            final var discreteField = sliderField("xs:integer", "0", "100", "50", "0", "10", "20");
391            holder.bind(session.new Field(
392                    eu.siacs.conversations.xmpp.forms.Field.parse(discreteField),
393                    session.TYPE_SLIDER_FIELD));
394
395            final var continuousField = sliderField(
396                    "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");
397            holder.bind(session.new Field(
398                    eu.siacs.conversations.xmpp.forms.Field.parse(continuousField),
399                    session.TYPE_SLIDER_FIELD));
400
401            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
402            Assert.assertEquals(1f, binding.slider.getValueTo(), 0.0001f);
403            Assert.assertEquals(0.5f, binding.slider.getValue(), 0.0001f);
404            Assert.assertEquals(0.1f, binding.slider.getStepSize(), 0.0001f);
405            binding.slider.measure(
406                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
407                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY));
408            binding.slider.layout(0, 0, 100, 100);
409            binding.slider.draw(new Canvas(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)));
410        } finally {
411            session.loadingTimer.cancel();
412        }
413    }
414
415    private static Element sliderField(
416            final String datatype,
417            final String min,
418            final String max,
419            final String value,
420            final String... options) {
421        final var field = new Element("field", Namespace.DATA);
422        field.setAttribute("type", "text-single");
423        final var validate = field.addChild("validate", "http://jabber.org/protocol/xdata-validate");
424        validate.setAttribute("datatype", datatype);
425        final var range = validate.addChild("range", "http://jabber.org/protocol/xdata-validate");
426        range.setAttribute("min", min);
427        range.setAttribute("max", max);
428        if (value != null) {
429            field.addChild("value", Namespace.DATA).setContent(value);
430        }
431        for (final String option : options) {
432            field.addChild("option", Namespace.DATA)
433                    .addChild("value", Namespace.DATA)
434                    .setContent(option);
435        }
436        return field;
437    }
438}