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}