1/*
2 * Copyright (c) 2018, Daniel Gultsch All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without modification,
5 * are permitted provided that the following conditions are met:
6 *
7 * 1. Redistributions of source code must retain the above copyright notice, this
8 * list of conditions and the following disclaimer.
9 *
10 * 2. Redistributions in binary form must reproduce the above copyright notice,
11 * this list of conditions and the following disclaimer in the documentation and/or
12 * other materials provided with the distribution.
13 *
14 * 3. Neither the name of the copyright holder nor the names of its contributors
15 * may be used to endorse or promote products derived from this software without
16 * specific prior written permission.
17 *
18 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
22 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 */
29
30package eu.siacs.conversations.ui;
31
32import static androidx.recyclerview.widget.ItemTouchHelper.LEFT;
33import static androidx.recyclerview.widget.ItemTouchHelper.RIGHT;
34
35import android.app.Activity;
36import android.app.Fragment;
37import android.content.Intent;
38import android.graphics.Canvas;
39import android.os.Bundle;
40import android.util.Log;
41import android.view.LayoutInflater;
42import android.view.Menu;
43import android.view.MenuInflater;
44import android.view.MenuItem;
45import android.view.View;
46import android.view.ViewGroup;
47import android.widget.Toast;
48import androidx.annotation.NonNull;
49import androidx.databinding.DataBindingUtil;
50import androidx.recyclerview.widget.ItemTouchHelper;
51import androidx.recyclerview.widget.LinearLayoutManager;
52import androidx.recyclerview.widget.RecyclerView;
53import com.google.android.material.dialog.MaterialAlertDialogBuilder;
54import com.google.android.material.snackbar.Snackbar;
55import com.google.common.collect.Collections2;
56import eu.siacs.conversations.BuildConfig;
57import eu.siacs.conversations.Config;
58import eu.siacs.conversations.R;
59import eu.siacs.conversations.databinding.FragmentConversationsOverviewBinding;
60import eu.siacs.conversations.entities.Account;
61import eu.siacs.conversations.entities.Conversation;
62import eu.siacs.conversations.entities.Conversational;
63import eu.siacs.conversations.services.QuickConversationsService;
64import eu.siacs.conversations.ui.adapter.ConversationAdapter;
65import eu.siacs.conversations.ui.interfaces.OnConversationArchived;
66import eu.siacs.conversations.ui.interfaces.OnConversationSelected;
67import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
68import eu.siacs.conversations.ui.util.PendingActionHelper;
69import eu.siacs.conversations.ui.util.PendingItem;
70import eu.siacs.conversations.ui.util.ScrollState;
71import eu.siacs.conversations.utils.AccountUtils;
72import eu.siacs.conversations.utils.EasyOnboardingInvite;
73import java.util.ArrayList;
74import java.util.List;
75import java.util.concurrent.atomic.AtomicReference;
76
77public class ConversationsOverviewFragment extends XmppFragment {
78
79 private static final String STATE_SCROLL_POSITION =
80 ConversationsOverviewFragment.class.getName() + ".scroll_state";
81
82 private final List<Conversation> conversations = new ArrayList<>();
83 private final PendingItem<Conversation> swipedConversation = new PendingItem<>();
84 private final PendingItem<ScrollState> pendingScrollState = new PendingItem<>();
85 private FragmentConversationsOverviewBinding binding;
86 private ConversationAdapter conversationsAdapter;
87 private XmppActivity activity;
88 private final PendingActionHelper pendingActionHelper = new PendingActionHelper();
89
90 private final ItemTouchHelper.SimpleCallback callback =
91 new ItemTouchHelper.SimpleCallback(0, LEFT | RIGHT) {
92 @Override
93 public boolean onMove(
94 @NonNull RecyclerView recyclerView,
95 @NonNull RecyclerView.ViewHolder viewHolder,
96 @NonNull RecyclerView.ViewHolder target) {
97 return false;
98 }
99
100 @Override
101 public void onChildDraw(
102 @NonNull Canvas c,
103 @NonNull RecyclerView recyclerView,
104 @NonNull RecyclerView.ViewHolder viewHolder,
105 float dX,
106 float dY,
107 int actionState,
108 boolean isCurrentlyActive) {
109 if (viewHolder
110 instanceof
111 ConversationAdapter.ConversationViewHolder conversationViewHolder) {
112 getDefaultUIUtil()
113 .onDraw(
114 c,
115 recyclerView,
116 conversationViewHolder.binding.frame,
117 dX,
118 dY,
119 actionState,
120 isCurrentlyActive);
121 }
122 }
123
124 @Override
125 public void clearView(
126 @NonNull RecyclerView recyclerView,
127 @NonNull RecyclerView.ViewHolder viewHolder) {
128 if (viewHolder
129 instanceof
130 ConversationAdapter.ConversationViewHolder conversationViewHolder) {
131 getDefaultUIUtil().clearView(conversationViewHolder.binding.frame);
132 }
133 }
134
135 @Override
136 public float getSwipeEscapeVelocity(final float defaultEscapeVelocity) {
137 return 32 * defaultEscapeVelocity;
138 }
139
140 @Override
141 public void onSwiped(
142 final RecyclerView.ViewHolder viewHolder, final int direction) {
143 pendingActionHelper.execute();
144 int position = viewHolder.getLayoutPosition();
145 try {
146 swipedConversation.push(conversations.get(position));
147 } catch (IndexOutOfBoundsException e) {
148 return;
149 }
150 conversationsAdapter.remove(swipedConversation.peek(), position);
151 activity.xmppConnectionService.markRead(swipedConversation.peek());
152
153 if (position == 0 && conversationsAdapter.getItemCount() == 0) {
154 final Conversation c = swipedConversation.pop();
155 activity.xmppConnectionService.archiveConversation(c);
156 return;
157 }
158 final boolean formerlySelected =
159 ConversationFragment.getConversation(getActivity())
160 == swipedConversation.peek();
161 if (activity instanceof OnConversationArchived) {
162 ((OnConversationArchived) activity)
163 .onConversationArchived(swipedConversation.peek());
164 }
165 final Conversation c = swipedConversation.peek();
166 final int title;
167 if (c.getMode() == Conversational.MODE_MULTI) {
168 if (c.getMucOptions().isPrivateAndNonAnonymous()) {
169 title = R.string.title_undo_swipe_out_group_chat;
170 } else {
171 title = R.string.title_undo_swipe_out_channel;
172 }
173 } else {
174 title = R.string.title_undo_swipe_out_chat;
175 }
176
177 final Snackbar snackbar =
178 Snackbar.make(binding.list, title, 5000)
179 .setAction(
180 R.string.undo,
181 v -> {
182 pendingActionHelper.undo();
183 Conversation conversation =
184 swipedConversation.pop();
185 conversationsAdapter.insert(conversation, position);
186 if (formerlySelected) {
187 if (activity
188 instanceof OnConversationSelected) {
189 ((OnConversationSelected) activity)
190 .onConversationSelected(c);
191 }
192 }
193 LinearLayoutManager layoutManager =
194 (LinearLayoutManager)
195 binding.list.getLayoutManager();
196 if (position
197 > layoutManager
198 .findLastVisibleItemPosition()) {
199 binding.list.smoothScrollToPosition(position);
200 }
201 })
202 .addCallback(
203 new Snackbar.Callback() {
204 @Override
205 public void onDismissed(
206 Snackbar transientBottomBar, int event) {
207 switch (event) {
208 case DISMISS_EVENT_SWIPE:
209 case DISMISS_EVENT_TIMEOUT:
210 pendingActionHelper.execute();
211 break;
212 }
213 }
214 });
215
216 pendingActionHelper.push(
217 () -> {
218 if (snackbar.isShownOrQueued()) {
219 snackbar.dismiss();
220 }
221 final Conversation conversation = swipedConversation.pop();
222 if (conversation != null) {
223 if (!conversation.isRead()
224 && conversation.getMode() == Conversation.MODE_SINGLE) {
225 return;
226 }
227 activity.xmppConnectionService.archiveConversation(c);
228 }
229 });
230 snackbar.show();
231 }
232 };
233
234 private ItemTouchHelper touchHelper;
235
236 public static Conversation getSuggestion(Activity activity) {
237 final Conversation exception;
238 Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
239 if (fragment instanceof ConversationsOverviewFragment) {
240 exception = ((ConversationsOverviewFragment) fragment).swipedConversation.peek();
241 } else {
242 exception = null;
243 }
244 return getSuggestion(activity, exception);
245 }
246
247 public static Conversation getSuggestion(Activity activity, Conversation exception) {
248 Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
249 if (fragment instanceof ConversationsOverviewFragment) {
250 List<Conversation> conversations =
251 ((ConversationsOverviewFragment) fragment).conversations;
252 if (conversations.size() > 0) {
253 Conversation suggestion = conversations.get(0);
254 if (suggestion == exception) {
255 if (conversations.size() > 1) {
256 return conversations.get(1);
257 }
258 } else {
259 return suggestion;
260 }
261 }
262 }
263 return null;
264 }
265
266 @Override
267 public void onActivityCreated(Bundle savedInstanceState) {
268 super.onActivityCreated(savedInstanceState);
269 if (savedInstanceState == null) {
270 return;
271 }
272 pendingScrollState.push(savedInstanceState.getParcelable(STATE_SCROLL_POSITION));
273 }
274
275 @Override
276 public void onAttach(Activity activity) {
277 super.onAttach(activity);
278 if (activity instanceof XmppActivity) {
279 this.activity = (XmppActivity) activity;
280 } else {
281 throw new IllegalStateException(
282 "Trying to attach fragment to activity that is not an XmppActivity");
283 }
284 }
285
286 @Override
287 public void onDestroyView() {
288 Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onDestroyView()");
289 super.onDestroyView();
290 this.binding = null;
291 this.conversationsAdapter = null;
292 this.touchHelper = null;
293 }
294
295 @Override
296 public void onDestroy() {
297 Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onDestroy()");
298 super.onDestroy();
299 }
300
301 @Override
302 public void onPause() {
303 Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onPause()");
304 pendingActionHelper.execute();
305 super.onPause();
306 }
307
308 @Override
309 public void onDetach() {
310 super.onDetach();
311 this.activity = null;
312 }
313
314 @Override
315 public void onCreate(Bundle savedInstanceState) {
316 super.onCreate(savedInstanceState);
317 setHasOptionsMenu(true);
318 }
319
320 @Override
321 public View onCreateView(
322 final LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
323 this.binding =
324 DataBindingUtil.inflate(
325 inflater, R.layout.fragment_conversations_overview, container, false);
326 this.binding.fab.setOnClickListener(
327 (view) -> StartConversationActivity.launch(getActivity()));
328
329 this.conversationsAdapter = new ConversationAdapter(this.activity, this.conversations);
330 this.conversationsAdapter.setConversationClickListener(
331 (view, conversation) -> {
332 if (activity instanceof OnConversationSelected) {
333 ((OnConversationSelected) activity).onConversationSelected(conversation);
334 } else {
335 Log.w(
336 ConversationsOverviewFragment.class.getCanonicalName(),
337 "Activity does not implement OnConversationSelected");
338 }
339 });
340 this.binding.list.setAdapter(this.conversationsAdapter);
341 this.binding.list.setLayoutManager(
342 new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false));
343 this.binding.list.addOnScrollListener(ExtendedFabSizeChanger.of(binding.fab));
344 this.touchHelper = new ItemTouchHelper(this.callback);
345 this.touchHelper.attachToRecyclerView(this.binding.list);
346 return binding.getRoot();
347 }
348
349 @Override
350 public void onCreateOptionsMenu(Menu menu, MenuInflater menuInflater) {
351 menuInflater.inflate(R.menu.fragment_conversations_overview, menu);
352 AccountUtils.showHideMenuItems(menu);
353 final MenuItem easyOnboardInvite = menu.findItem(R.id.action_easy_invite);
354 easyOnboardInvite.setVisible(
355 EasyOnboardingInvite.anyHasSupport(
356 activity == null ? null : activity.xmppConnectionService));
357 final MenuItem privacyPolicyMenuItem = menu.findItem(R.id.action_privacy_policy);
358 privacyPolicyMenuItem.setVisible(
359 BuildConfig.PRIVACY_POLICY != null
360 && QuickConversationsService.isPlayStoreFlavor());
361 }
362
363 @Override
364 public void onBackendConnected() {
365 refresh();
366 }
367
368 @Override
369 public void onSaveInstanceState(Bundle bundle) {
370 super.onSaveInstanceState(bundle);
371 ScrollState scrollState = getScrollState();
372 if (scrollState != null) {
373 bundle.putParcelable(STATE_SCROLL_POSITION, scrollState);
374 }
375 }
376
377 private ScrollState getScrollState() {
378 if (this.binding == null) {
379 return null;
380 }
381 if (this.binding.list.getLayoutManager()
382 instanceof LinearLayoutManager linearLayoutManager) {
383 final int position = linearLayoutManager.findFirstVisibleItemPosition();
384 final View view = this.binding.list.getChildAt(0);
385 if (view != null) {
386 return new ScrollState(position, view.getTop());
387 } else {
388 return new ScrollState(position, 0);
389 }
390 }
391 return null;
392 }
393
394 @Override
395 public void onStart() {
396 super.onStart();
397 Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onStart()");
398 if (activity.xmppConnectionService != null) {
399 refresh();
400 }
401 }
402
403 @Override
404 public void onResume() {
405 super.onResume();
406 Log.d(Config.LOGTAG, "ConversationsOverviewFragment.onResume()");
407 }
408
409 @Override
410 public boolean onOptionsItemSelected(final MenuItem item) {
411 if (MenuDoubleTabUtil.shouldIgnoreTap()) {
412 return false;
413 }
414 switch (item.getItemId()) {
415 case R.id.action_search:
416 startActivity(new Intent(getActivity(), SearchActivity.class));
417 return true;
418 case R.id.action_easy_invite:
419 selectAccountToStartEasyInvite();
420 return true;
421 }
422 return super.onOptionsItemSelected(item);
423 }
424
425 private void selectAccountToStartEasyInvite() {
426 final List<Account> accounts =
427 EasyOnboardingInvite.getSupportingAccounts(activity.xmppConnectionService);
428 if (accounts.isEmpty()) {
429 // This can technically happen if opening the menu item races with accounts reconnecting
430 // or something
431 Toast.makeText(
432 getActivity(),
433 R.string.no_active_accounts_support_this,
434 Toast.LENGTH_LONG)
435 .show();
436 } else if (accounts.size() == 1) {
437 openEasyInviteScreen(accounts.get(0));
438 } else {
439 final AtomicReference<Account> selectedAccount = new AtomicReference<>(accounts.get(0));
440 final MaterialAlertDialogBuilder alertDialogBuilder =
441 new MaterialAlertDialogBuilder(activity);
442 alertDialogBuilder.setTitle(R.string.choose_account);
443 final String[] asStrings =
444 Collections2.transform(accounts, a -> a.getJid().asBareJid().toString())
445 .toArray(new String[0]);
446 alertDialogBuilder.setSingleChoiceItems(
447 asStrings, 0, (dialog, which) -> selectedAccount.set(accounts.get(which)));
448 alertDialogBuilder.setNegativeButton(R.string.cancel, null);
449 alertDialogBuilder.setPositiveButton(
450 R.string.ok, (dialog, which) -> openEasyInviteScreen(selectedAccount.get()));
451 alertDialogBuilder.create().show();
452 }
453 }
454
455 private void openEasyInviteScreen(final Account account) {
456 EasyOnboardingInviteActivity.launch(account, activity);
457 }
458
459 @Override
460 void refresh() {
461 if (this.binding == null || this.activity == null) {
462 Log.d(
463 Config.LOGTAG,
464 "ConversationsOverviewFragment.refresh() skipped updated because view binding"
465 + " or activity was null");
466 return;
467 }
468 this.activity.xmppConnectionService.populateWithOrderedConversations(this.conversations);
469 Conversation removed = this.swipedConversation.peek();
470 if (removed != null) {
471 if (removed.isRead()) {
472 this.conversations.remove(removed);
473 } else {
474 pendingActionHelper.execute();
475 }
476 }
477 this.conversationsAdapter.notifyDataSetChanged();
478 final var scrollState = pendingScrollState.pop();
479 if (scrollState != null) {
480 setScrollPosition(scrollState);
481 }
482 }
483
484 private void setScrollPosition(@NonNull final ScrollState scrollPosition) {
485 if (binding.list.getLayoutManager() instanceof LinearLayoutManager linearLayoutManager) {
486 linearLayoutManager.scrollToPositionWithOffset(
487 scrollPosition.position, scrollPosition.offset);
488 if (scrollPosition.position > 0) {
489 binding.fab.shrink();
490 } else {
491 binding.fab.extend();
492 }
493 binding.fab.clearAnimation();
494 }
495 }
496}