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 formatSliderValueDoesNotClampLargeIntegerDatatypesToInt() {
216        Assert.assertEquals("3000000000", Conversation.formatSliderValue(3_000_000_000f, "xs:long"));
217    }
218
219    @Test
220    public void formatSliderValueUsesPlainDecimalLexicalFormForDecimalDatatype() {
221        Assert.assertEquals("0.000001", Conversation.formatSliderValue(0.000001f, "xs:decimal"));
222    }
223
224    @Test
225    public void steppedSliderStepRejectsIncompatibleBounds() {
226        final var field = sliderField("xs:integer", "0", "70", "34", "0", "34", "68");
227
228        Assert.assertNull(Conversation.steppedSliderStep(field));
229    }
230
231    @Test
232    public void steppedSliderStepRejectsMalformedOptions() {
233        final var field = sliderField("xs:integer", "0", "70", "35", "0", "not-a-number", "70");
234
235        Assert.assertNull(Conversation.steppedSliderStep(field));
236    }
237
238    @Test
239    public void steppedSliderStepRejectsValuesOutsideRange() {
240        final var below = sliderField("xs:integer", "0", "70", "-5", "0", "35", "70");
241        final var above = sliderField("xs:integer", "0", "70", "999", "0", "35", "70");
242
243        Assert.assertNull(Conversation.steppedSliderStep(below));
244        Assert.assertNull(Conversation.steppedSliderStep(above));
245    }
246
247    @Test
248    public void steppedSliderStepRejectsDuplicateOptions() {
249        final var field = sliderField("xs:integer", "0", "70", "35", "0", "35", "35", "70");
250
251        Assert.assertNull(Conversation.steppedSliderStep(field));
252    }
253
254    @Test
255    public void rangedListSingleWithCustomValueFallsBackToTextInputWhenSliderCannotRepresentIt() {
256        final var session = withOccupantId.pagerAdapter.new CommandSession(
257                "test", "node", mock(XmppConnectionService.class));
258        try {
259            session.responseElement = new Element("x", Namespace.DATA);
260            session.responseElement.setAttribute("type", "form");
261            final var field = sliderField("xs:integer", "0", "68", "7", "0", "34", "68");
262            field.setAttribute("type", "list-single");
263
264            Assert.assertEquals(session.TYPE_TEXT_FIELD, session.mkField(field).viewType);
265        } finally {
266            session.loadingTimer.cancel();
267        }
268    }
269
270    @Test
271    public void sliderFieldViewHolderBindsEmptyValueWithoutCrashing() {
272        final var context = new ContextThemeWrapper(
273                RuntimeEnvironment.getApplication(),
274                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
275        final var session = withOccupantId.pagerAdapter.new CommandSession(
276                "test", "node", mock(XmppConnectionService.class));
277        try {
278            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
279            final var holder = session.new SliderFieldViewHolder(binding);
280            final var field = sliderField("xs:integer", "0", "70", "", "0", "35", "70");
281            final var item = session.new Field(
282                    eu.siacs.conversations.xmpp.forms.Field.parse(field),
283                    session.TYPE_SLIDER_FIELD);
284
285            holder.bind(item);
286
287            Assert.assertEquals(0f, binding.slider.getValue(), 0.0001f);
288        } finally {
289            session.loadingTimer.cancel();
290        }
291    }
292
293    @Test
294    public void sliderFieldViewHolderRebindsDifferentStepSizesWithoutCrashing() {
295        final var context = new ContextThemeWrapper(
296                RuntimeEnvironment.getApplication(),
297                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
298        final var session = withOccupantId.pagerAdapter.new CommandSession(
299                "test", "node", mock(XmppConnectionService.class));
300        try {
301            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
302            final var holder = session.new SliderFieldViewHolder(binding);
303            final var steppedField = sliderField("xs:integer", "0", "70", "35", "0", "35", "70");
304            holder.bind(session.new Field(
305                    eu.siacs.conversations.xmpp.forms.Field.parse(steppedField),
306                    session.TYPE_SLIDER_FIELD));
307
308            final var integerField = sliderField("xs:integer", "0", "10", "7");
309            holder.bind(session.new Field(
310                    eu.siacs.conversations.xmpp.forms.Field.parse(integerField),
311                    session.TYPE_SLIDER_FIELD));
312
313            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
314            Assert.assertEquals(10f, binding.slider.getValueTo(), 0.0001f);
315            Assert.assertEquals(7f, binding.slider.getValue(), 0.0001f);
316            Assert.assertEquals(1f, binding.slider.getStepSize(), 0.0001f);
317        } finally {
318            session.loadingTimer.cancel();
319        }
320    }
321
322    @Test
323    public void sliderFieldViewHolderRebindsFromLargeDiscreteToSmallDiscreteRangeWithoutCrashing() {
324        final var context = new ContextThemeWrapper(
325                RuntimeEnvironment.getApplication(),
326                com.google.android.material.R.style.Theme_MaterialComponents_DayNight_NoActionBar);
327        final var session = withOccupantId.pagerAdapter.new CommandSession(
328                "test", "node", mock(XmppConnectionService.class));
329        try {
330            final var binding = CommandSliderFieldBinding.inflate(LayoutInflater.from(context));
331            final var holder = session.new SliderFieldViewHolder(binding);
332            final var discreteField = sliderField("xs:integer", "0", "100", "50", "0", "10", "20");
333            holder.bind(session.new Field(
334                    eu.siacs.conversations.xmpp.forms.Field.parse(discreteField),
335                    session.TYPE_SLIDER_FIELD));
336
337            final var continuousField = sliderField(
338                    "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");
339            holder.bind(session.new Field(
340                    eu.siacs.conversations.xmpp.forms.Field.parse(continuousField),
341                    session.TYPE_SLIDER_FIELD));
342
343            Assert.assertEquals(0f, binding.slider.getValueFrom(), 0.0001f);
344            Assert.assertEquals(1f, binding.slider.getValueTo(), 0.0001f);
345            Assert.assertEquals(0.5f, binding.slider.getValue(), 0.0001f);
346            Assert.assertEquals(0.1f, binding.slider.getStepSize(), 0.0001f);
347            binding.slider.measure(
348                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY),
349                    View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.EXACTLY));
350            binding.slider.layout(0, 0, 100, 100);
351            binding.slider.draw(new Canvas(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)));
352        } finally {
353            session.loadingTimer.cancel();
354        }
355    }
356
357    private static Element sliderField(
358            final String datatype,
359            final String min,
360            final String max,
361            final String value,
362            final String... options) {
363        final var field = new Element("field", Namespace.DATA);
364        field.setAttribute("type", "text-single");
365        final var validate = field.addChild("validate", "http://jabber.org/protocol/xdata-validate");
366        validate.setAttribute("datatype", datatype);
367        final var range = validate.addChild("range", "http://jabber.org/protocol/xdata-validate");
368        range.setAttribute("min", min);
369        range.setAttribute("max", max);
370        if (value != null) {
371            field.addChild("value", Namespace.DATA).setContent(value);
372        }
373        for (final String option : options) {
374            field.addChild("option", Namespace.DATA)
375                    .addChild("value", Namespace.DATA)
376                    .setContent(option);
377        }
378        return field;
379    }
380}