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}