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}