From 0465fdea62f3b3738d25c8805002a7aa90b1e56b Mon Sep 17 00:00:00 2001 From: mtang-signal Date: Tue, 14 May 2024 09:22:24 -0700 Subject: [PATCH] Update contacts permission UI. --- .../securesms/ContactSelectionListAdapter.kt | 36 +++++ .../ContactSelectionListFragment.java | 144 +++++++++--------- .../securesms/keyvalue/UiHints.java | 9 ++ .../res/drawable/permissions_contact_book.xml | 31 ++++ ...ct_selection_find_contacts_banner_item.xml | 73 +++++++++ .../contact_selection_find_contacts_item.xml | 57 +++++++ .../contact_selection_list_fragment.xml | 65 -------- app/src/main/res/values/strings.xml | 12 ++ 8 files changed, 292 insertions(+), 135 deletions(-) create mode 100644 app/src/main/res/drawable/permissions_contact_book.xml create mode 100644 app/src/main/res/layout/contact_selection_find_contacts_banner_item.xml create mode 100644 app/src/main/res/layout/contact_selection_find_contacts_item.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt index ab63ad61d1..54c76eaf2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms import android.content.Context import android.view.View import android.widget.TextView +import com.google.android.material.button.MaterialButton import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchData @@ -24,6 +25,8 @@ class ContactSelectionListAdapter( init { registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item)) registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item)) + registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item)) + registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item)) registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item)) registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)) registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)) @@ -46,6 +49,16 @@ class ContactSelectionListAdapter( override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true } + class FindContactsModel : MappingModel { + override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true + override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true + } + + class FindContactsBannerModel : MappingModel { + override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true + override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true + } + class FindByUsernameModel : MappingModel { override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true @@ -86,6 +99,23 @@ class ContactSelectionListAdapter( override fun bind(model: RefreshContactsModel) = Unit } + private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindContactsModel) = Unit + } + + private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.findViewById(R.id.no_thanks_button).setOnClickListener { onDismissListener() } + itemView.findViewById(R.id.allow_contacts_button).setOnClickListener { onClickListener() } + } + + override fun bind(model: FindContactsBannerModel) = Unit + } + private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder(itemView) { private val headerTextView: TextView = itemView.findViewById(R.id.section_header) @@ -129,6 +159,8 @@ class ContactSelectionListAdapter( INVITE_TO_SIGNAL("invite-to-signal"), MORE_HEADING("more-heading"), REFRESH_CONTACTS("refresh-contacts"), + FIND_CONTACTS("find-contacts"), + FIND_CONTACTS_BANNER("find-contacts-banner"), FIND_BY_USERNAME("find-by-username"), FIND_BY_PHONE_NUMBER("find-by-phone-number"); @@ -152,6 +184,8 @@ class ContactSelectionListAdapter( ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel() ArbitraryRow.MORE_HEADING -> MoreHeaderModel() ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel() + ArbitraryRow.FIND_CONTACTS -> FindContactsModel() + ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel() ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel() ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel() } @@ -162,6 +196,8 @@ class ContactSelectionListAdapter( fun onNewGroupClicked() fun onInviteToSignalClicked() fun onRefreshContactsClicked() + fun onFindContactsClicked() + fun onDismissFindContactsBannerClicked() fun onFindByPhoneNumberClicked() fun onFindByUsernameClicked() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 4a15618ab8..924c144454 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -70,13 +70,13 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.manage.UsernameRepository; import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.CommunicationActions; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.ViewUtil; @@ -125,10 +125,6 @@ public final class ContactSelectionListFragment extends LoggingFragment { private TextView emptyText; private OnContactSelectedListener onContactSelectedListener; private SwipeRefreshLayout swipeRefresh; - private View showContactsLayout; - private Button showContactsButton; - private TextView showContactsDescription; - private ProgressWheel showContactsProgress; private String cursorFilter; private RecyclerView recyclerView; private RecyclerViewFastScroller fastScroller; @@ -223,43 +219,25 @@ public final class ContactSelectionListFragment extends LoggingFragment { public void onStart() { super.onStart(); - Permissions.with(this) - .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) - .ifNecessary() - .onAllGranted(() -> { - if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { - handleContactPermissionGranted(); - } else { - contactSearchMediator.refresh(); - } - }) - .onAnyDenied(() -> { - requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - - if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) { - contactSearchMediator.refresh(); - } else { - initializeNoContactsPermission(); - } - }) - .execute(); + if (hasContactsPermissions(requireContext()) && !TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { + handleContactPermissionGranted(); + } else { + requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + contactSearchMediator.refresh(); + } } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); - emptyText = view.findViewById(android.R.id.empty); - recyclerView = view.findViewById(R.id.recycler_view); - swipeRefresh = view.findViewById(R.id.swipe_refresh); - fastScroller = view.findViewById(R.id.fast_scroller); - showContactsLayout = view.findViewById(R.id.show_contacts_container); - showContactsButton = view.findViewById(R.id.show_contacts_button); - showContactsDescription = view.findViewById(R.id.show_contacts_description); - showContactsProgress = view.findViewById(R.id.progress); - chipRecycler = view.findViewById(R.id.chipRecycler); - constraintLayout = view.findViewById(R.id.container); - headerActionView = view.findViewById(R.id.header_action); + emptyText = view.findViewById(android.R.id.empty); + recyclerView = view.findViewById(R.id.recycler_view); + swipeRefresh = view.findViewById(R.id.swipe_refresh); + fastScroller = view.findViewById(R.id.fast_scroller); + chipRecycler = view.findViewById(R.id.chipRecycler); + constraintLayout = view.findViewById(R.id.container); + headerActionView = view.findViewById(R.id.header_action); final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext()); @@ -269,6 +247,11 @@ public final class ContactSelectionListFragment extends LoggingFragment { public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { return true; } + + @Override + public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { + recyclerView.setAlpha(1f); + } }); contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class); @@ -372,6 +355,19 @@ public final class ContactSelectionListFragment extends LoggingFragment { fixedContacts, displayOptions, new ContactSelectionListAdapter.OnContactSelectionClick() { + @Override + public void onDismissFindContactsBannerClicked() { + SignalStore.uiHints().markDismissedContactsPermissionBanner(); + if (onRefreshListener != null) { + onRefreshListener.onRefresh(); + } + } + + @Override + public void onFindContactsClicked() { + requestContactPermissions(); + } + @Override public void onRefreshContactsClicked() { if (onRefreshListener != null) { @@ -498,6 +494,27 @@ public final class ContactSelectionListFragment extends LoggingFragment { return isMulti; } + private void requestContactPermissions() { + Permissions.with(this) + .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) + .ifNecessary() + .onAllGranted(() -> { + recyclerView.setAlpha(0.5f); + if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { + handleContactPermissionGranted(); + } else { + contactSearchMediator.refresh(); + if (onRefreshListener != null) { + swipeRefresh.setRefreshing(true); + onRefreshListener.onRefresh(); + } + } + }) + .onAnyDenied(() -> contactSearchMediator.refresh()) + .withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager()) + .execute(); + } + private void initializeCursor() { recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)); recyclerView.setAdapter(contactSearchMediator.getAdapter()); @@ -521,28 +538,6 @@ public final class ContactSelectionListFragment extends LoggingFragment { return hasQueryFilter() || shouldDisplayRecents(); } - private void initializeNoContactsPermission() { - swipeRefresh.setVisibility(View.GONE); - - showContactsLayout.setVisibility(View.VISIBLE); - showContactsProgress.setVisibility(View.INVISIBLE); - showContactsDescription.setText(R.string.contact_selection_list_fragment__signal_needs_access_to_your_contacts_in_order_to_display_them); - showContactsButton.setVisibility(View.VISIBLE); - - showContactsButton.setOnClickListener(v -> { - Permissions.with(this) - .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) - .ifNecessary() - .withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts)) - .onSomeGranted(permissions -> { - if (permissions.contains(Manifest.permission.WRITE_CONTACTS)) { - handleContactPermissionGranted(); - } - }) - .execute(); - }); - } - public void setQueryFilter(String filter) { if (Objects.equals(filter, this.cursorFilter)) { return; @@ -583,7 +578,6 @@ public final class ContactSelectionListFragment extends LoggingFragment { } swipeRefresh.setVisibility(View.VISIBLE); - showContactsLayout.setVisibility(View.GONE); emptyText.setText(R.string.contact_selection_group_activity__no_contacts); boolean useFastScroller = count > 20; @@ -614,12 +608,10 @@ public final class ContactSelectionListFragment extends LoggingFragment { new AsyncTask() { @Override protected void onPreExecute() { - swipeRefresh.setVisibility(View.GONE); - showContactsLayout.setVisibility(View.VISIBLE); - showContactsButton.setVisibility(View.INVISIBLE); - showContactsDescription.setText(R.string.ConversationListFragment_loading); - showContactsProgress.setVisibility(View.VISIBLE); - showContactsProgress.spin(); + if (onRefreshListener != null) { + setRefreshing(true); + onRefreshListener.onRefresh(); + } } @Override @@ -636,14 +628,11 @@ public final class ContactSelectionListFragment extends LoggingFragment { @Override protected void onPostExecute(Boolean result) { if (result) { - showContactsLayout.setVisibility(View.GONE); - swipeRefresh.setVisibility(View.VISIBLE); reset(); } else { Context context = getContext(); if (context != null) { Toast.makeText(getContext(), R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection, Toast.LENGTH_LONG).show(); - initializeNoContactsPermission(); } } } @@ -890,6 +879,13 @@ public final class ContactSelectionListFragment extends LoggingFragment { return ContactSearchConfiguration.build(builder -> { builder.setQuery(contactSearchState.getQuery()); + if (newConversationCallback != null && + !hasContactsPermissions(requireContext()) && + !SignalStore.uiHints().getDismissedContactsPermissionBanner() && + !hasQuery) { + builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode()); + } + if (newConversationCallback != null && !hasQuery) { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode()); } @@ -946,7 +942,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { builder.username(newRowMode); } - if ((newCallCallback != null || newConversationCallback != null) && !hasQuery) { + if ((newCallCallback != null || newConversationCallback != null)) { addMoreSection(builder); builder.withEmptyState(emptyBuilder -> { emptyBuilder.addSection(ContactSearchConfiguration.Section.Empty.INSTANCE); @@ -959,9 +955,17 @@ public final class ContactSelectionListFragment extends LoggingFragment { }); } + private boolean hasContactsPermissions(@NonNull Context context) { + return Permissions.hasAll(context, Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS); + } + private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) { builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode()); - builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode()); + if (hasContactsPermissions(requireContext())) { + builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode()); + } else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) { + builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode()); + } builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java index de3acba570..cbcc878c97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -24,6 +24,7 @@ public class UiHints extends SignalStoreValues { private static final String LAST_CRASH_PROMPT = "uihints.last_crash_prompt"; private static final String HAS_COMPLETED_USERNAME_ONBOARDING = "uihints.has_completed_username_onboarding"; private static final String HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET = "uihints.has_seen_double_tap_edit_education_sheet"; + private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner"; UiHints(@NonNull KeyValueStore store) { super(store); @@ -167,4 +168,12 @@ public class UiHints extends SignalStoreValues { public boolean getHasSeenDoubleTapEditEducationSheet() { return getBoolean(HAS_SEEN_DOUBLE_TAP_EDIT_EDUCATION_SHEET, false); } + + public void markDismissedContactsPermissionBanner() { + putBoolean(DISMISSED_CONTACTS_PERMISSION_BANNER, true); + } + + public boolean getDismissedContactsPermissionBanner() { + return getBoolean(DISMISSED_CONTACTS_PERMISSION_BANNER, false); + } } diff --git a/app/src/main/res/drawable/permissions_contact_book.xml b/app/src/main/res/drawable/permissions_contact_book.xml new file mode 100644 index 0000000000..974cd2beae --- /dev/null +++ b/app/src/main/res/drawable/permissions_contact_book.xml @@ -0,0 +1,31 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/contact_selection_find_contacts_banner_item.xml b/app/src/main/res/layout/contact_selection_find_contacts_banner_item.xml new file mode 100644 index 0000000000..04e99d3af4 --- /dev/null +++ b/app/src/main/res/layout/contact_selection_find_contacts_banner_item.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/contact_selection_find_contacts_item.xml b/app/src/main/res/layout/contact_selection_find_contacts_item.xml new file mode 100644 index 0000000000..2ddcf305d0 --- /dev/null +++ b/app/src/main/res/layout/contact_selection_find_contacts_item.xml @@ -0,0 +1,57 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 5673b11e73..86529c36fc 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -48,71 +48,6 @@ app:layout_constraintTop_toBottomOf="@+id/chipRecycler" tools:visibility="visible" /> - - - - - - - - - - - - - - - - Refresh contacts Missing someone? Try refreshing + + Find people you know on Signal + + Allow access to your contacts More @@ -2747,6 +2751,14 @@ Find by phone number Find by username + + Allow access to contacts + + To find people you know on Signal: + + Allow access + + Allow access to your contacts. Your contacts are encrypted and not visible to the Signal service. Signal needs access to your contacts in order to display them.