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