diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5d5ce3e91..0ab4c863b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -483,8 +483,13 @@ android:theme="@style/TextSecure.LightNoActionBar" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" /> + - + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index 1dd2b6176b..1f1d3d2d63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -50,6 +50,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB { private static final String TAG = ContactSelectionActivity.class.getSimpleName(); + public static final String EXTRA_LAYOUT_RES_ID = "layout_res_id"; + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); @@ -67,11 +69,11 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB protected void onCreate(Bundle icicle, boolean ready) { if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { int displayMode = TextSecurePreferences.isSmsEnabled(this) ? DisplayMode.FLAG_ALL - : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS; + : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF; getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); } - setContentView(R.layout.contact_selection_activity); + setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity)); initializeToolbar(); initializeResources(); @@ -90,7 +92,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB } private void initializeToolbar() { - this.toolbar = ViewUtil.findById(this, R.id.toolbar); + this.toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); assert getSupportActionBar() != null; diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 2715f97348..d59b2d216d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -187,10 +187,14 @@ public final class ContactSelectionListFragment extends Fragment Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } - @NonNull List getSelectedContacts() { + public @NonNull List getSelectedContacts() { return cursorRecyclerViewAdapter.getSelectedContacts(); } + public int getSelectedContactsCount() { + return cursorRecyclerViewAdapter.getSelectedContactsCount(); + } + private boolean isMulti() { return requireActivity().getIntent().getBooleanExtra(MULTI_SELECT, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 5e462bf7c3..e268aeb963 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -26,6 +26,7 @@ import android.view.View; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -97,7 +98,7 @@ public class NewConversationActivity extends ContactSelectionActivity } private void handleCreateGroup() { - startActivity(new Intent(this, GroupCreateActivity.class)); + startActivity(CreateGroupActivity.newIntent(this)); } private void handleInvite() { @@ -105,10 +106,10 @@ public class NewConversationActivity extends ContactSelectionActivity } @Override - protected boolean onPrepareOptionsPanel(View view, Menu menu) { - MenuInflater inflater = this.getMenuInflater(); + public boolean onPrepareOptionsMenu(Menu menu) { menu.clear(); - inflater.inflate(R.menu.new_conversation_activity, menu); + getMenuInflater().inflate(R.menu.new_conversation_activity, menu); + super.onPrepareOptionsMenu(menu); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index c17952cf1f..5916b28a62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -158,13 +158,12 @@ public final class AvatarImageView extends AppCompatImageView { } private void setAvatarClickHandler(final Recipient recipient, boolean quickContactEnabled) { - super.setOnClickListener(v -> { - if (quickContactEnabled) { - getContext().startActivity(RecipientPreferenceActivity.getLaunchIntent(getContext(), recipient.getId())); - } else if (listener != null) { - listener.onClick(v); - } - }); + if (quickContactEnabled) { + super.setOnClickListener(v -> getContext().startActivity(RecipientPreferenceActivity.getLaunchIntent(getContext(), recipient.getId()))); + } else { + super.setOnClickListener(listener); + setClickable(listener != null); + } } private static class RecipientContactPhoto { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java index c7a1a83893..e3c913cc43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -85,11 +85,15 @@ public class ContactRepository { @WorkerThread public Cursor querySignalContacts(@NonNull String query) { - Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts() - : recipientDatabase.querySignalContacts(query); + return querySignalContacts(query, true); + } + @WorkerThread + public Cursor querySignalContacts(@NonNull String query, boolean includeSelf) { + Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf) + : recipientDatabase.querySignalContacts(query, includeSelf); - if (noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) { + if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) { Recipient self = Recipient.self(); boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase()); boolean numberMatch = self.getE164().isPresent() && self.requireE164().contains(query); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java index c272a9236c..f3d3530335 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactSelectionListAdapter.java @@ -250,6 +250,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter(contacts); } + public int size() { + return contacts.size(); + } + public void clear() { contacts.clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 151f96d4fc..e893d8f6fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -1357,17 +1357,29 @@ public class RecipientDatabase extends Database { } } public @Nullable Cursor getSignalContacts() { + return getSignalContacts(true); + } + + public @Nullable Cursor getSignalContacts(boolean includeSelf) { String selection = BLOCKED + " = ? AND " + REGISTERED + " = ? AND " + GROUP_ID + " IS NULL AND " + "(" + SORT_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)"; - String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) }; + String[] args; + + if (includeSelf) { + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) }; + } else { + selection += " AND " + ID + " != ?"; + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), String.valueOf(Recipient.self().getId().toLong()) }; + } + String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE; return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); } - public @Nullable Cursor querySignalContacts(@NonNull String query) { + public @Nullable Cursor querySignalContacts(@NonNull String query, boolean includeSelf) { query = TextUtils.isEmpty(query) ? "*" : query; query = "%" + query + "%"; @@ -1379,7 +1391,15 @@ public class RecipientDatabase extends Database { SORT_NAME + " LIKE ? OR " + USERNAME + " LIKE ?" + ")"; - String[] args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query }; + String[] args; + + if (includeSelf) { + args = new String[]{"0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query}; + } else { + selection += " AND " + ID + " != ?"; + args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query, String.valueOf(Recipient.self().getId().toLong()) }; + } + String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE; return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java index 0081da68fe..24df4655a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -31,7 +31,49 @@ public abstract class GroupMemberEntry { @Override public abstract int hashCode(); - abstract boolean sameId(GroupMemberEntry newItem); + abstract boolean sameId(@NonNull GroupMemberEntry newItem); + + public final static class NewGroupCandidate extends GroupMemberEntry { + + private final DefaultValueLiveData isSelected = new DefaultValueLiveData<>(false); + private final Recipient member; + + public NewGroupCandidate(@NonNull Recipient member) { + this.member = member; + } + + public @NonNull Recipient getMember() { + return member; + } + + public @NonNull LiveData isSelected() { + return isSelected; + } + + public void setSelected(boolean isSelected) { + this.isSelected.postValue(isSelected); + } + + @Override + boolean sameId(@NonNull GroupMemberEntry newItem) { + if (getClass() != newItem.getClass()) return false; + + return member.getId().equals(((NewGroupCandidate) newItem).member.getId()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof NewGroupCandidate)) return false; + + NewGroupCandidate other = (NewGroupCandidate) obj; + return other.member.equals(member); + } + + @Override + public int hashCode() { + return member.hashCode(); + } + } public final static class FullMember extends GroupMemberEntry { @@ -52,7 +94,7 @@ public abstract class GroupMemberEntry { } @Override - boolean sameId(GroupMemberEntry newItem) { + boolean sameId(@NonNull GroupMemberEntry newItem) { if (getClass() != newItem.getClass()) return false; return member.getId().equals(((GroupMemberEntry.FullMember) newItem).member.getId()); @@ -97,7 +139,7 @@ public abstract class GroupMemberEntry { } @Override - boolean sameId(GroupMemberEntry newItem) { + boolean sameId(@NonNull GroupMemberEntry newItem) { if (getClass() != newItem.getClass()) return false; return invitee.getId().equals(((GroupMemberEntry.PendingMember) newItem).invitee.getId()); @@ -153,7 +195,7 @@ public abstract class GroupMemberEntry { } @Override - boolean sameId(GroupMemberEntry newItem) { + boolean sameId(@NonNull GroupMemberEntry newItem) { if (getClass() != newItem.getClass()) return false; return inviter.getId().equals(((GroupMemberEntry.UnknownPendingMemberCount) newItem).inviter.getId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java index 06c17e8d78..3a0fa53504 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.LifecycleRecyclerAdapter; import org.thoughtcrime.securesms.util.LifecycleViewHolder; +import org.thoughtcrime.securesms.util.ThemeUtil; import java.util.ArrayList; import java.util.List; @@ -26,11 +27,13 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter data = new ArrayList<>(); - @Nullable private AdminActionsListener adminActionsListener; - @Nullable private RecipientClickListener recipientClickListener; + @Nullable private AdminActionsListener adminActionsListener; + @Nullable private RecipientClickListener recipientClickListener; + @Nullable private RecipientLongClickListener recipientLongClickListener; void updateData(@NonNull List recipients) { if (data.isEmpty()) { @@ -49,16 +52,25 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter { + if (recipientLongClickListener != null && getAdapterPosition() != RecyclerView.NO_POSITION) { + return recipientLongClickListener.onLongClick(recipient); + } + + return false; + }); } void bind(@NonNull GroupMemberEntry memberEntry) { @@ -179,9 +207,10 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter recipients) { membersAdapter.updateData(recipients); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java new file mode 100644 index 0000000000..b0d2a73b1a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientLongClickListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +public interface RecipientLongClickListener { + boolean onLongClick(@NonNull Recipient recipient); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java new file mode 100644 index 0000000000..f1aa5bdcca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -0,0 +1,107 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.ContactSelectionActivity; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; +import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; + +public class CreateGroupActivity extends ContactSelectionActivity { + + private static final int MINIMUM_GROUP_SIZE = 1; + private static final short REQUEST_CODE_ADD_DETAILS = 17275; + + private View next; + + public static Intent newIntent(@NonNull Context context) { + Intent intent = new Intent(context, CreateGroupActivity.class); + + intent.putExtra(ContactSelectionListFragment.MULTI_SELECT, true); + intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false); + intent.putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.create_group_activity); + + int displayMode = TextSecurePreferences.isSmsEnabled(context) ? ContactsCursorLoader.DisplayMode.FLAG_SMS | ContactsCursorLoader.DisplayMode.FLAG_PUSH + : ContactsCursorLoader.DisplayMode.FLAG_PUSH; + + intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode); + + return intent; + } + + @Override + public void onCreate(Bundle bundle, boolean ready) { + super.onCreate(bundle, ready); + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + next = findViewById(R.id.next); + + disableNext(); + next.setOnClickListener(v -> handleNextPressed()); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == REQUEST_CODE_ADD_DETAILS && resultCode == RESULT_OK) { + finish(); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + public void onContactSelected(Optional recipientId, String number) { + if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SIZE) { + enableNext(); + } + } + + @Override + public void onContactDeselected(Optional recipientId, String number) { + if (contactsFragment.getSelectedContactsCount() < MINIMUM_GROUP_SIZE) { + disableNext(); + } + } + + private void enableNext() { + next.setEnabled(true); + next.animate().alpha(1f); + } + + private void disableNext() { + next.setEnabled(false); + next.animate().alpha(0.5f); + } + + private void handleNextPressed() { + RecipientId[] ids = Stream.of(contactsFragment.getSelectedContacts()) + .map(selectedContact -> selectedContact.getOrCreateRecipientId(this)) + .toArray(RecipientId[]::new); + + startActivityForResult(AddGroupDetailsActivity.newIntent(this, ids), REQUEST_CODE_ADD_DETAILS); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java new file mode 100644 index 0000000000..dddb8f62c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsActivity.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.navigation.NavGraph; +import androidx.navigation.Navigation; + +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +public class AddGroupDetailsActivity extends PassphraseRequiredActionBarActivity implements AddGroupDetailsFragment.Callback { + + private static final String EXTRA_RECIPIENTS = "recipient_ids"; + + private final DynamicTheme theme = new DynamicNoActionBarTheme(); + + public static Intent newIntent(@NonNull Context context, @NonNull RecipientId[] recipients) { + Intent intent = new Intent(context, AddGroupDetailsActivity.class); + + intent.putExtra(EXTRA_RECIPIENTS, recipients); + + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle bundle, boolean ready) { + theme.onCreate(this); + + setContentView(R.layout.add_group_details_activity); + + if (bundle == null) { + Parcelable[] parcelables = getIntent().getParcelableArrayExtra(EXTRA_RECIPIENTS); + RecipientId[] ids = new RecipientId[parcelables.length]; + + System.arraycopy(parcelables, 0, ids, 0, parcelables.length); + + AddGroupDetailsFragmentArgs arguments = new AddGroupDetailsFragmentArgs.Builder(ids).build(); + NavGraph graph = Navigation.findNavController(this, R.id.nav_host_fragment).getGraph(); + + Navigation.findNavController(this, R.id.nav_host_fragment).setGraph(graph, arguments.toBundle()); + } + } + + @Override + protected void onResume() { + super.onResume(); + theme.onResume(this); + } + + @Override + public void onGroupCreated(@NonNull RecipientId recipientId, long threadId) { + Intent intent = ConversationActivity.buildIntent(this, + recipientId, + threadId, + ThreadDatabase.DistributionTypes.DEFAULT, + -1); + + startActivity(intent); + setResult(RESULT_OK); + finish(); + } + + @Override + public void onNavigationButtonPressed() { + setResult(RESULT_CANCELED); + finish(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java new file mode 100644 index 0000000000..3d152eca04 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsFragment.java @@ -0,0 +1,287 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.os.Bundle; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.transition.Transition; +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; +import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.profiles.AvatarHelper; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +import java.util.Objects; + +public class AddGroupDetailsFragment extends Fragment { + + private static final int AVATAR_PLACEHOLDER_INSET_DP = 18; + private static final short REQUEST_CODE_AVATAR = 27621; + private static final String ARG_RECIPIENT_IDS = "recipient_ids"; + + private CircularProgressButton create; + private Callback callback; + private AddGroupDetailsViewModel viewModel; + private Drawable avatarPlaceholder; + private EditText name; + private Toolbar toolbar; + private ActionMode actionMode; + + private ActionMode.Callback recipientActionModeCallback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.add_group_details_fragment_context_menu, menu); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (item.getItemId() == R.id.action_delete) { + viewModel.deleteSelected(); + mode.finish(); + return true; + } + + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + viewModel.clearSelected(); + } + }; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof Callback) { + callback = (Callback) context; + } else { + throw new ClassCastException("Parent context should implement AddGroupDetailsFragment.Callback"); + } + } + + public static Fragment create(@NonNull RecipientId[] recipientIds) { + AddGroupDetailsFragment fragment = new AddGroupDetailsFragment(); + Bundle arguments = new Bundle(); + + arguments.putParcelableArray(ARG_RECIPIENT_IDS, recipientIds); + fragment.setArguments(arguments); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.add_group_details_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + create = view.findViewById(R.id.create); + name = view.findViewById(R.id.group_name); + toolbar = view.findViewById(R.id.toolbar); + + setCreateEnabled(false, false); + + GroupMemberListView members = view.findViewById(R.id.member_list); + ImageView avatar = view.findViewById(R.id.group_avatar); + View mmsWarning = view.findViewById(R.id.mms_warning); + + avatarPlaceholder = VectorDrawableCompat.create(getResources(), R.drawable.ic_camera_outline_32_ultramarine, requireActivity().getTheme()); + + if (savedInstanceState == null) { + avatar.setImageDrawable(new InsetDrawable(avatarPlaceholder, ViewUtil.dpToPx(AVATAR_PLACEHOLDER_INSET_DP))); + } + + initializeViewModel(); + + avatar.setOnClickListener(v -> AvatarSelectionBottomSheetDialogFragment.create(false, true, REQUEST_CODE_AVATAR, true) + .show(getChildFragmentManager(), "BOTTOM")); + members.setRecipientLongClickListener(this::handleRecipientLongClick); + members.setRecipientClickListener(this::handleRecipientClick); + name.addTextChangedListener(new AfterTextChanged(editable -> viewModel.setName(editable.toString()))); + toolbar.setNavigationOnClickListener(unused -> callback.onNavigationButtonPressed()); + create.setOnClickListener(v -> handleCreateClicked()); + viewModel.getMembers().observe(getViewLifecycleOwner(), members::setMembers); + viewModel.getCanSubmitForm().observe(getViewLifecycleOwner(), isFormValid -> setCreateEnabled(isFormValid, true)); + viewModel.getIsMms().observe(getViewLifecycleOwner(), isMms -> mmsWarning.setVisibility(isMms ? View.VISIBLE : View.GONE)); + viewModel.getAvatar().observe(getViewLifecycleOwner(), avatarBytes -> { + if (avatarBytes == null) { + avatar.setImageDrawable(new InsetDrawable(avatarPlaceholder, ViewUtil.dpToPx(AVATAR_PLACEHOLDER_INSET_DP))); + } else { + GlideApp.with(this) + .load(avatarBytes) + .circleCrop() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(avatar); + } + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == REQUEST_CODE_AVATAR && resultCode == Activity.RESULT_OK && data != null) { + final Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA); + final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri()); + + GlideApp.with(this) + .asBitmap() + .load(decryptableUri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .centerCrop() + .override(AvatarHelper.AVATAR_DIMENSIONS, AvatarHelper.AVATAR_DIMENSIONS) + .into(new CustomTarget() { + @Override + public void onResourceReady(@NonNull Bitmap resource, Transition transition) { + viewModel.setAvatar(Objects.requireNonNull(BitmapUtil.toByteArray(resource))); + } + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) { + } + }); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + private void initializeViewModel() { + AddGroupDetailsFragmentArgs args = AddGroupDetailsFragmentArgs.fromBundle(requireArguments()); + AddGroupDetailsRepository repository = new AddGroupDetailsRepository(requireContext()); + AddGroupDetailsViewModel.Factory factory = new AddGroupDetailsViewModel.Factory(args.getRecipientIds(), repository); + + viewModel = ViewModelProviders.of(this, factory).get(AddGroupDetailsViewModel.class); + + viewModel.getGroupCreateResult().observe(getViewLifecycleOwner(), this::handleGroupCreateResult); + } + + private void handleCreateClicked() { + create.setClickable(false); + create.setIndeterminateProgressMode(true); + create.setProgress(50); + + viewModel.create(); + } + + private void handleRecipientClick(@NonNull Recipient recipient) { + if (actionMode == null) { + return; + } + + int size = viewModel.toggleSelected(recipient); + if (size == 0) { + actionMode.finish(); + } + } + + private boolean handleRecipientLongClick(@NonNull Recipient recipient) { + if (actionMode != null) { + return false; + } + + actionMode = toolbar.startActionMode(recipientActionModeCallback); + + if (actionMode != null) { + viewModel.toggleSelected(recipient); + return true; + } + + return false; + } + + private void handleGroupCreateResult(@NonNull GroupCreateResult groupCreateResult) { + groupCreateResult.consume(this::handleGroupCreateResultSuccess, this::handleGroupCreateResultError); + } + + private void handleGroupCreateResultSuccess(@NonNull GroupCreateResult.Success success) { + callback.onGroupCreated(success.getGroupRecipient().getId(), success.getThreadId()); + } + + private void handleGroupCreateResultError(@NonNull GroupCreateResult.Error error) { + switch (error.getErrorType()) { + case ERROR_IO: + case ERROR_BUSY: + toast(R.string.AddGroupDetailsFragment__try_again_later); + break; + case ERROR_FAILED: + toast(R.string.AddGroupDetailsFragment__group_creation_failed); + break; + case ERROR_INVALID_NAME: + name.setError(getString(R.string.AddGroupDetailsFragment__this_field_is_required)); + break; + case ERROR_INVALID_MEMBER_COUNT: + toast(R.string.AddGroupDetailsFragment__groups_require_at_least_two_members); + break; + default: + throw new IllegalStateException("Unexpected error: " + error.getErrorType().name()); + } + } + + private void toast(@StringRes int toastStringId) { + Toast.makeText(requireContext(), toastStringId, Toast.LENGTH_SHORT) + .show(); + } + + private void setCreateEnabled(boolean isEnabled, boolean animate) { + if (create.isEnabled() == isEnabled) { + return; + } + + create.setEnabled(isEnabled); + create.animate() + .setDuration(animate ? 300 : 0) + .alpha(isEnabled ? 1f : 0.5f); + } + + public interface Callback { + void onGroupCreated(@NonNull RecipientId recipientId, long threadId); + void onNavigationButtonPressed(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java new file mode 100644 index 0000000000..5272fd6441 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsRepository.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class AddGroupDetailsRepository { + + private final Context context; + + AddGroupDetailsRepository(@NonNull Context context) { + this.context = context; + } + + void resolveMembers(@NonNull RecipientId[] recipientIds, Consumer> consumer) { + SignalExecutors.BOUNDED.execute(() -> { + List members = new ArrayList<>(recipientIds.length); + + for (RecipientId id : recipientIds) { + members.add(new GroupMemberEntry.NewGroupCandidate(Recipient.resolved(id))); + } + + consumer.accept(members); + }); + } + + void createPushGroup(@NonNull Set members, + @Nullable byte[] avatar, + @Nullable String name, + boolean mms, + Consumer resultConsumer) + { + SignalExecutors.BOUNDED.execute(() -> { + Set recipients = new HashSet<>(Stream.of(members).map(Recipient::resolved).toList()); + + try { + GroupManager.GroupActionResult result = GroupManager.createGroup(context, recipients, avatar, name, mms); + + resultConsumer.accept(GroupCreateResult.success(result)); + } catch (GroupChangeBusyException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_BUSY)); + } catch (GroupChangeFailedException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_FAILED)); + } catch (IOException e) { + resultConsumer.accept(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_IO)); + } + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java new file mode 100644 index 0000000000..16206c1907 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/AddGroupDetailsViewModel.java @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class AddGroupDetailsViewModel extends ViewModel { + + private final LiveData> members; + private final DefaultValueLiveData> selected = new DefaultValueLiveData<>(new HashSet<>()); + private final DefaultValueLiveData> deleted = new DefaultValueLiveData<>(new HashSet<>()); + private final MutableLiveData name = new MutableLiveData<>(""); + private final MutableLiveData avatar = new MutableLiveData<>(); + private final LiveData isMms; + private final SingleLiveEvent groupCreateResult = new SingleLiveEvent<>(); + private final LiveData canSubmitForm = Transformations.map(name, name -> !TextUtils.isEmpty(name)); + private final AddGroupDetailsRepository repository; + + AddGroupDetailsViewModel(@NonNull RecipientId[] recipientIds, + @NonNull AddGroupDetailsRepository repository) + { + this.repository = repository; + + MutableLiveData> initialMembers = new MutableLiveData<>(); + LiveData> membersWithoutDeleted = LiveDataUtil.combineLatest(initialMembers, + deleted, + AddGroupDetailsViewModel::filterDeletedMembers); + + members = LiveDataUtil.combineLatest(membersWithoutDeleted, selected, AddGroupDetailsViewModel::updateSelectedMembers); + isMms = Transformations.map(members, this::isAnyForcedSms); + + repository.resolveMembers(recipientIds, initialMembers::postValue); + } + + @NonNull LiveData> getMembers() { + return members; + } + + @NonNull LiveData getCanSubmitForm() { + return canSubmitForm; + } + + @NonNull LiveData getGroupCreateResult() { + return groupCreateResult; + } + + @NonNull LiveData getAvatar() { + return avatar; + } + + @NonNull LiveData getIsMms() { + return isMms; + } + + void setAvatar(@NonNull byte[] avatar) { + this.avatar.setValue(avatar); + } + + void setName(@NonNull String name) { + this.name.setValue(name); + } + + int toggleSelected(@NonNull Recipient recipient) { + Set selected = this.selected.getValue(); + + if (!selected.add(recipient.getId())) { + selected.remove(recipient.getId()); + } + + this.selected.setValue(selected); + + return selected.size(); + } + + void clearSelected() { + this.selected.setValue(new HashSet<>()); + } + + void deleteSelected() { + Set selected = this.selected.getValue(); + Set deleted = this.deleted.getValue(); + + deleted.addAll(selected); + this.deleted.setValue(deleted); + } + + void create() { + List members = Objects.requireNonNull(this.members.getValue()); + Set memberIds = Stream.of(members).map(member -> member.getMember().getId()).collect(Collectors.toSet()); + byte[] avatarBytes = avatar.getValue(); + String groupName = name.getValue(); + boolean isGroupMms = isMms.getValue() == Boolean.TRUE; + + if (TextUtils.isEmpty(groupName)) { + groupCreateResult.postValue(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_INVALID_NAME)); + return; + } + + if (memberIds.isEmpty()) { + groupCreateResult.postValue(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_INVALID_MEMBER_COUNT)); + return; + } + + repository.createPushGroup(memberIds, + avatarBytes, + groupName, + isGroupMms, + groupCreateResult::postValue); + } + + private static @NonNull List filterDeletedMembers(@NonNull List members, @NonNull Set deleted) { + return Stream.of(members) + .filterNot(member -> deleted.contains(member.getMember().getId())) + .toList(); + } + + private static @NonNull List updateSelectedMembers(@NonNull List members, @NonNull Set selected) { + for (GroupMemberEntry.NewGroupCandidate member : members) { + member.setSelected(selected.contains(member.getMember().getId())); + } + + return members; + } + + private boolean isAnyForcedSms(@NonNull List members) { + return Stream.of(members) + .anyMatch(member -> !member.getMember().isRegistered()); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final RecipientId[] recipientIds; + private final AddGroupDetailsRepository repository; + + Factory(@NonNull RecipientId[] recipientIds, @NonNull AddGroupDetailsRepository repository) { + this.recipientIds = recipientIds; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return Objects.requireNonNull(modelClass.cast(new AddGroupDetailsViewModel(recipientIds, repository))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java new file mode 100644 index 0000000000..d95b147523 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/details/GroupCreateResult.java @@ -0,0 +1,77 @@ +package org.thoughtcrime.securesms.groups.ui.creategroup.details; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.recipients.Recipient; + +abstract class GroupCreateResult { + + static GroupCreateResult success(@NonNull GroupManager.GroupActionResult result) { + return new GroupCreateResult.Success(result.getThreadId(), result.getGroupRecipient()); + } + + static GroupCreateResult error(@NonNull GroupCreateResult.Error.Type errorType) { + return new GroupCreateResult.Error(errorType); + } + + private GroupCreateResult() { + } + + static final class Success extends GroupCreateResult { + private final long threadId; + private final Recipient groupRecipient; + + private Success(long threadId, @NonNull Recipient groupRecipient) { + this.threadId = threadId; + this.groupRecipient = groupRecipient; + } + + long getThreadId() { + return threadId; + } + + @NonNull Recipient getGroupRecipient() { + return groupRecipient; + } + + @Override + void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer) + { + successConsumer.accept(this); + } + } + + static final class Error extends GroupCreateResult { + private final Error.Type errorType; + + private Error(Error.Type errorType) { + this.errorType = errorType; + } + + @Override + void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer) + { + errorConsumer.accept(this); + } + + public Type getErrorType() { + return errorType; + } + + enum Type { + ERROR_IO, + ERROR_BUSY, + ERROR_FAILED, + ERROR_INVALID_NAME, + ERROR_INVALID_MEMBER_COUNT + } + } + + abstract void consume(@NonNull Consumer successConsumer, + @NonNull Consumer errorConsumer); + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java index fc2c16f908..9713d61bea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionBottomSheetDialogFragment.java @@ -32,7 +32,7 @@ public class AvatarSelectionBottomSheetDialogFragment extends BottomSheetDialogF private static final String ARG_REQUEST_CODE = "request_code"; private static final String ARG_IS_GROUP = "is_group"; - public static DialogFragment create(boolean includeClear, boolean includeCamera, short resultCode, boolean isGroup) { + public static DialogFragment create(boolean includeClear, boolean includeCamera, short requestCode, boolean isGroup) { DialogFragment fragment = new AvatarSelectionBottomSheetDialogFragment(); List selectionOptions = new ArrayList<>(3); Bundle args = new Bundle(); @@ -52,7 +52,7 @@ public class AvatarSelectionBottomSheetDialogFragment extends BottomSheetDialogF .toArray(String[]::new); args.putStringArray(ARG_OPTIONS, options); - args.putShort(ARG_REQUEST_CODE, resultCode); + args.putShort(ARG_REQUEST_CODE, requestCode); args.putBoolean(ARG_IS_GROUP, isGroup); fragment.setArguments(args); diff --git a/app/src/main/res/drawable-v21/group_candidate_item_background_dark.xml b/app/src/main/res/drawable-v21/group_candidate_item_background_dark.xml new file mode 100644 index 0000000000..f191271836 --- /dev/null +++ b/app/src/main/res/drawable-v21/group_candidate_item_background_dark.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/group_candidate_item_background_light.xml b/app/src/main/res/drawable-v21/group_candidate_item_background_light.xml new file mode 100644 index 0000000000..08ccc39dd6 --- /dev/null +++ b/app/src/main/res/drawable-v21/group_candidate_item_background_light.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/contact_selection_checkbox.xml b/app/src/main/res/drawable/contact_selection_checkbox.xml new file mode 100644 index 0000000000..c1228f8cd9 --- /dev/null +++ b/app/src/main/res/drawable/contact_selection_checkbox.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_candidate_item_background_dark.xml b/app/src/main/res/drawable/group_candidate_item_background_dark.xml new file mode 100644 index 0000000000..c2336004d1 --- /dev/null +++ b/app/src/main/res/drawable/group_candidate_item_background_dark.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/group_candidate_item_background_light.xml b/app/src/main/res/drawable/group_candidate_item_background_light.xml new file mode 100644 index 0000000000..2de40201c8 --- /dev/null +++ b/app/src/main/res/drawable/group_candidate_item_background_light.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_right_24.xml b/app/src/main/res/drawable/ic_arrow_right_24.xml new file mode 100644 index 0000000000..7b443d8450 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_right_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml b/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml new file mode 100644 index 0000000000..82feba1281 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_outline_32_ultramarine.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_check_outline_22.xml b/app/src/main/res/drawable/ic_check_outline_22.xml new file mode 100644 index 0000000000..9f2ebd3d51 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_outline_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_outline_24.xml b/app/src/main/res/drawable/ic_error_outline_24.xml new file mode 100644 index 0000000000..db18e74c79 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tinted_circle_dark.xml b/app/src/main/res/drawable/tinted_circle_dark.xml new file mode 100644 index 0000000000..c56381ea67 --- /dev/null +++ b/app/src/main/res/drawable/tinted_circle_dark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tinted_circle_light.xml b/app/src/main/res/drawable/tinted_circle_light.xml new file mode 100644 index 0000000000..ddb1cd660d --- /dev/null +++ b/app/src/main/res/drawable/tinted_circle_light.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_group_details_activity.xml b/app/src/main/res/layout/add_group_details_activity.xml new file mode 100644 index 0000000000..b193a4772e --- /dev/null +++ b/app/src/main/res/layout/add_group_details_activity.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_group_details_fragment.xml b/app/src/main/res/layout/add_group_details_fragment.xml new file mode 100644 index 0000000000..0a5a48a49f --- /dev/null +++ b/app/src/main/res/layout/add_group_details_fragment.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/contact_selection_list_item.xml b/app/src/main/res/layout/contact_selection_list_item.xml index f1549b3fa7..0101d54112 100644 --- a/app/src/main/res/layout/contact_selection_list_item.xml +++ b/app/src/main/res/layout/contact_selection_list_item.xml @@ -1,31 +1,47 @@ - + - + + + + + + @@ -61,7 +77,8 @@ android:paddingStart="10dip" android:ellipsize="end" android:singleLine="true" - android:textAppearance="?android:attr/textAppearanceSmall" + android:textAppearance="@style/TextAppearance.Signal.Body2" + android:textColor="?attr/title_text_color_secondary" android:fontFamily="sans-serif-light" tools:text="@sample/contacts.json/data/label" tools:ignore="RtlSymmetry" /> @@ -70,11 +87,4 @@ - - diff --git a/app/src/main/res/layout/create_group_activity.xml b/app/src/main/res/layout/create_group_activity.xml new file mode 100644 index 0000000000..05a58f2e3b --- /dev/null +++ b/app/src/main/res/layout/create_group_activity.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/group_new_candidate_recipient_list_item.xml b/app/src/main/res/layout/group_new_candidate_recipient_list_item.xml new file mode 100644 index 0000000000..7cfad1012d --- /dev/null +++ b/app/src/main/res/layout/group_new_candidate_recipient_list_item.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/add_group_details_fragment_context_menu.xml b/app/src/main/res/menu/add_group_details_fragment_context_menu.xml new file mode 100644 index 0000000000..8156129fc0 --- /dev/null +++ b/app/src/main/res/menu/add_group_details_fragment_context_menu.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/create_group.xml b/app/src/main/res/navigation/create_group.xml new file mode 100644 index 0000000000..38ad375bea --- /dev/null +++ b/app/src/main/res/navigation/create_group.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 637d01b4f4..16186757de 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -139,6 +139,8 @@ + + @@ -226,6 +228,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 265b6968d7..9c4597a6fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -487,6 +487,19 @@ Error canceling invites + + Name this group + Create + Members + Group name (required) + This field is required. + Groups require at least two members. + Group creation failed. + Try again later. + You\'ve selected a contact that doesn\'t support Signal groups, so this group will be MMS. + Remove + SMS contact + Disappearing messages Pending group invites diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bd995361a2..06b8626c5f 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -188,6 +188,8 @@ @drawable/ic_arrow_left_24 + @drawable/group_candidate_item_background_light + @drawable/ic_kbs_splash_light_svg @color/white @@ -251,6 +253,8 @@ @color/white @color/transparent_white_90 + @drawable/tinted_circle_light + @drawable/contact_list_divider_light @color/debuglog_light_none @@ -475,6 +479,10 @@ @drawable/ic_arrow_left_24 @drawable/ic_arrow_left_24 + @drawable/group_candidate_item_background_dark + + @drawable/tinted_circle_dark + @drawable/ic_kbs_splash_dark_svg @color/core_grey_95