diff --git a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java index 79494f0172..a5d7ea0cb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/GroupMembersDialog.java @@ -3,12 +3,16 @@ package org.thoughtcrime.securesms; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LiveData; import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import java.util.List; + public final class GroupMembersDialog { private final FragmentActivity fragmentActivity; @@ -22,27 +26,28 @@ public final class GroupMembersDialog { } public void display() { - AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) - .setTitle(R.string.ConversationActivity_group_members) - .setIconAttribute(R.attr.group_members_dialog_icon) - .setCancelable(true) - .setView(R.layout.dialog_group_members) - .setPositiveButton(android.R.string.ok, null) - .show(); + AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) + .setTitle(R.string.ConversationActivity_group_members) + .setIconAttribute(R.attr.group_members_dialog_icon) + .setCancelable(true) + .setView(R.layout.dialog_group_members) + .setPositiveButton(android.R.string.ok, null) + .show(); - GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); + GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); - LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId()); + LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId()); + LiveData> fullMembers = liveGroup.getFullMembers(); - //noinspection ConstantConditions - liveGroup.getFullMembers().observe(fragmentActivity, memberListView::setMembers); + //noinspection ConstantConditions + fullMembers.observe(fragmentActivity, memberListView::setMembers); - dialog.setOnDismissListener(d -> liveGroup.removeObservers(fragmentActivity)); + dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity)); - memberListView.setRecipientClickListener(recipient -> { - dialog.dismiss(); - contactClick(recipient); - }); + memberListView.setRecipientClickListener(recipient -> { + dialog.dismiss(); + contactClick(recipient); + }); } private void contactClick(@NonNull Recipient recipient) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java index d2a1974bcb..6bb23b2ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.java @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms; +import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import java.util.concurrent.TimeUnit; @@ -23,6 +25,10 @@ public class MuteDialog extends AlertDialog { } public static void show(final Context context, final @NonNull MuteSelectionListener listener) { + show(context, listener, null); + } + + public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) { AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle(R.string.MuteDialog_mute_notifications); builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() { @@ -43,6 +49,13 @@ public class MuteDialog extends AlertDialog { } }); + if (cancelListener != null) { + builder.setOnCancelListener(dialog -> { + cancelListener.run(); + dialog.dismiss(); + }); + } + builder.show(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 0faa03803b..29af8a7286 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -5,7 +5,7 @@ import android.content.res.Resources; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; -import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Transformations; import com.annimon.stream.ComparatorCompat; @@ -16,78 +16,74 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; -import org.whispersystems.libsignal.util.guava.Optional; import java.util.Comparator; import java.util.List; -public final class LiveGroup extends MediatorLiveData { +public final class LiveGroup { private static final Comparator LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isLocalNumber(), m1.getMember().isLocalNumber()); private static final Comparator ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin()); private static final Comparator MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST) .thenComparing(ADMIN_FIRST); - private final GroupDatabase groupDatabase; - private final LiveData recipient; + private final GroupDatabase groupDatabase; + private final LiveData recipient; + private final LiveData groupRecord; public LiveGroup(@NonNull GroupId groupId) { - Context context = ApplicationDependencies.getApplication(); + Context context = ApplicationDependencies.getApplication(); + MutableLiveData liveRecipient = new MutableLiveData<>(); this.groupDatabase = DatabaseFactory.getGroupDatabase(context); + this.recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); + this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient-> groupDatabase.getGroup(groupRecipient.getId()).orNull())); - recipient = Recipient.externalGroup(context, groupId).live().getLiveData(); - - addSource(recipient, this::refresh); - } - - private void refresh(@NonNull Recipient groupRecipient) { - SignalExecutors.BOUNDED.execute(() -> { - Optional group = groupDatabase.getGroup(groupRecipient.getId()); - if (group.isPresent()) { - postValue(group.get()); - } - } - ); + SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroup(context, groupId).live())); } public LiveData getTitle() { - return Transformations.map(this, GroupDatabase.GroupRecord::getTitle); + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getTitle); + } + + public LiveData getGroupRecipient() { + return recipient; } public LiveData isSelfAdmin() { - return Transformations.map(this, g -> g.isAdmin(Recipient.self())); + return Transformations.map(groupRecord, g -> g.isAdmin(Recipient.self())); } public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { - return LiveDataUtil.mapAsync(this, g -> g.isAdmin(Recipient.resolved(recipientId))); + return LiveDataUtil.mapAsync(groupRecord, g -> g.isAdmin(Recipient.resolved(recipientId))); } public LiveData getPendingMemberCount() { - return Transformations.map(this, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0); + return Transformations.map(groupRecord, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0); } public LiveData getMembershipAdditionAccessControl() { - return Transformations.map(this, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl); + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl); } public LiveData getAttributesAccessControl() { - return Transformations.map(this, GroupDatabase.GroupRecord::getAttributesAccessControl); + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getAttributesAccessControl); } public LiveData> getFullMembers() { - return LiveDataUtil.mapAsync(this, - g -> Stream.of(g.getMembers()) - .map(m -> { - Recipient recipient = Recipient.resolved(m); - return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); - }) - .sorted(MEMBER_ORDER) - .toList()); + return LiveDataUtil.mapAsync(groupRecord, + g -> Stream.of(g.getMembers()) + .map(m -> { + Recipient recipient = Recipient.resolved(m); + return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); + }) + .sorted(MEMBER_ORDER) + .toList()); } public LiveData getExpireMessages() { @@ -98,16 +94,16 @@ public final class LiveGroup extends MediatorLiveData return LiveDataUtil.combineLatest(isSelfAdmin(), getAttributesAccessControl(), (admin, rights) -> { - switch (rights) { - case ALL_MEMBERS: - return true; - case ONLY_ADMINS: - return admin; - default: - throw new AssertionError(); - } + switch (rights) { + case ALL_MEMBERS: + return true; + case ONLY_ADMINS: + return admin; + default: + throw new AssertionError(); } - ); + } + ); } public LiveData getMembershipCountDescription(@NonNull Resources resources) { @@ -122,4 +118,4 @@ public final class LiveGroup extends MediatorLiveData : resources.getQuantityString(R.plurals.MessageRequestProfileView_members, fullMemberCount, fullMemberCount); } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 5722a8ca20..6ffa6be901 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -11,16 +11,20 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Button; +import android.widget.CompoundButton; +import android.widget.Switch; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.Group; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProviders; import org.thoughtcrime.securesms.MediaPreviewActivity; +import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThreadPhotoRailView; @@ -28,13 +32,17 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; +import org.thoughtcrime.securesms.groups.ui.notifications.CustomNotificationsDialogFragment; import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; +import org.thoughtcrime.securesms.util.DateUtils; +import java.util.Locale; import java.util.Objects; public class ManageGroupFragment extends Fragment { @@ -61,6 +69,10 @@ public class ManageGroupFragment extends Fragment { private Button disappearingMessages; private Button blockGroup; private Button leaveGroup; + private Switch muteNotificationsSwitch; + private TextView muteNotificationsUntilLabel; + private TextView customNotificationsButton; + private Group customNotificationsControls; static ManageGroupFragment newInstance(@NonNull String groupId) { ManageGroupFragment fragment = new ManageGroupFragment(); @@ -85,21 +97,25 @@ public class ManageGroupFragment extends Fragment { { View view = inflater.inflate(R.layout.group_manage_fragment, container, false); - avatar = view.findViewById(R.id.group_avatar); - groupTitle = view.findViewById(R.id.group_title); - memberCount = view.findViewById(R.id.member_count); - groupMemberList = view.findViewById(R.id.group_members); - listPending = view.findViewById(R.id.listPending); - threadPhotoRailView = view.findViewById(R.id.recent_photos); - groupMediaCard = view.findViewById(R.id.group_media_card); - accessControlCard = view.findViewById(R.id.group_access_control_card); - pendingMembersCard = view.findViewById(R.id.group_pending_card); - photoRailLabel = view.findViewById(R.id.rail_label); - editGroupAccessValue = view.findViewById(R.id.edit_group_access_value); - editGroupMembershipValue = view.findViewById(R.id.edit_group_membership_value); - disappearingMessages = view.findViewById(R.id.disappearing_messages); - blockGroup = view.findViewById(R.id.blockGroup); - leaveGroup = view.findViewById(R.id.leaveGroup); + avatar = view.findViewById(R.id.group_avatar); + groupTitle = view.findViewById(R.id.group_title); + memberCount = view.findViewById(R.id.member_count); + groupMemberList = view.findViewById(R.id.group_members); + listPending = view.findViewById(R.id.listPending); + threadPhotoRailView = view.findViewById(R.id.recent_photos); + groupMediaCard = view.findViewById(R.id.group_media_card); + accessControlCard = view.findViewById(R.id.group_access_control_card); + pendingMembersCard = view.findViewById(R.id.group_pending_card); + photoRailLabel = view.findViewById(R.id.rail_label); + editGroupAccessValue = view.findViewById(R.id.edit_group_access_value); + editGroupMembershipValue = view.findViewById(R.id.edit_group_membership_value); + disappearingMessages = view.findViewById(R.id.disappearing_messages); + blockGroup = view.findViewById(R.id.blockGroup); + leaveGroup = view.findViewById(R.id.leaveGroup); + muteNotificationsUntilLabel = view.findViewById(R.id.group_mute_notifications_until); + muteNotificationsSwitch = view.findViewById(R.id.group_mute_notifications_switch); + customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button); + customNotificationsControls = view.findViewById(R.id.group_custom_notifications_controls); return view; } @@ -185,6 +201,47 @@ public class ManageGroupFragment extends Fragment { viewModel.getCanEditGroupAttributes().observe(getViewLifecycleOwner(), canEdit -> disappearingMessages.setEnabled(canEdit)); groupMemberList.setRecipientClickListener(recipient -> RecipientBottomSheetDialogFragment.create(recipient.getId(), groupId).show(requireFragmentManager(), "BOTTOM")); + + final CompoundButton.OnCheckedChangeListener muteSwitchListener = (buttonView, isChecked) -> { + if (isChecked) { + MuteDialog.show(context, viewModel::setMuteUntil, () -> muteNotificationsSwitch.setChecked(false)); + } else { + viewModel.clearMuteUntil(); + } + }; + + viewModel.getMuteState().observe(getViewLifecycleOwner(), muteState -> { + if (muteNotificationsSwitch.isChecked() != muteState.isMuted()) { + muteNotificationsSwitch.setOnCheckedChangeListener(null); + muteNotificationsSwitch.setChecked(muteState.isMuted()); + } + + muteNotificationsSwitch.setEnabled(true); + muteNotificationsSwitch.setOnCheckedChangeListener(muteSwitchListener); + muteNotificationsUntilLabel.setVisibility(muteState.isMuted() ? View.VISIBLE : View.GONE); + + if (muteState.isMuted()) { + muteNotificationsUntilLabel.setText(getString(R.string.ManageGroupActivity_until_s, + DateUtils.getTimeString(requireContext(), + Locale.getDefault(), + muteState.getMutedUntil()))); + } + }); + + if (NotificationChannels.supported()) { + customNotificationsControls.setVisibility(View.VISIBLE); + + customNotificationsButton.setOnClickListener(v -> CustomNotificationsDialogFragment.create(groupId) + .show(requireFragmentManager(), "CUSTOM_NOTIFICATIONS")); + + //noinspection CodeBlock2Expr + viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { + customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageGroupActivity_on + : R.string.ManageGroupActivity_off); + }); + } else { + customNotificationsControls.setVisibility(View.GONE); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index e371a45f9a..bf9b79ce67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; @@ -101,6 +102,13 @@ final class ManageGroupRepository { recipientCallback::accept); } + public void setMuteUntil(long until) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientId recipientId = Recipient.externalGroup(context, groupId).getId(); + DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until); + }); + } + static final class GroupStateResult { private final long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index 2fa2509de0..74c4243e13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import java.util.List; @@ -44,6 +43,8 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData editMembershipRights; private final LiveData editGroupAttributesRights; private final MutableLiveData groupViewState = new MutableLiveData<>(null); + private final LiveData muteState; + private final LiveData hasCustomNotifications; private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) { this.context = context; @@ -62,6 +63,10 @@ public class ManageGroupViewModel extends ViewModel { this.editGroupAttributesRights = liveGroup.getAttributesAccessControl(); this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration)); this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes(); + this.muteState = Transformations.map(liveGroup.getGroupRecipient(), + recipient -> new MuteState(recipient.getMuteUntil(), recipient.isMuted())); + this.hasCustomNotifications = Transformations.map(liveGroup.getGroupRecipient(), + recipient -> recipient.getNotificationChannel() != null); } @WorkerThread @@ -91,6 +96,10 @@ public class ManageGroupViewModel extends ViewModel { return title; } + LiveData getMuteState() { + return muteState; + } + LiveData getMembershipRights() { return editMembershipRights; } @@ -111,6 +120,10 @@ public class ManageGroupViewModel extends ViewModel { return disappearingMessageTimer; } + LiveData hasCustomNotifications() { + return hasCustomNotifications; + } + void handleExpirationSelection() { manageGroupRepository.getRecipient(groupRecipient -> ExpirationDialog.show(context, @@ -131,6 +144,14 @@ public class ManageGroupViewModel extends ViewModel { () -> RecipientUtil.block(context, recipient))); } + void setMuteUntil(long muteUntil) { + manageGroupRepository.setMuteUntil(muteUntil); + } + + void clearMuteUntil() { + manageGroupRepository.setMuteUntil(0); + } + @WorkerThread private void showErrorToast(@NonNull ManageGroupRepository.FailureReason e) { Util.runOnMain(() -> Toast.makeText(context, e.getToastMessage(), Toast.LENGTH_SHORT).show()); @@ -163,6 +184,24 @@ public class ManageGroupViewModel extends ViewModel { } } + static final class MuteState { + private final long mutedUntil; + private final boolean isMuted; + + MuteState(long mutedUntil, boolean isMuted) { + this.mutedUntil = mutedUntil; + this.isMuted = isMuted; + } + + public long getMutedUntil() { + return mutedUntil; + } + + public boolean isMuted() { + return isMuted; + } + } + interface CursorFactory { Cursor create(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsDialogFragment.java new file mode 100644 index 0000000000..fc89d1d013 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsDialogFragment.java @@ -0,0 +1,176 @@ +package org.thoughtcrime.securesms.groups.ui.notifications; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.util.ThemeUtil; + +public class CustomNotificationsDialogFragment extends DialogFragment { + + private static final short RINGTONE_PICKER_REQUEST_CODE = 13562; + + private static final String ARG_GROUP_ID = "group_id"; + + private Switch customNotificationsSwitch; + private View soundLabel; + private TextView soundSelector; + private View vibrateLabel; + private Switch vibrateSwitch; + + private CustomNotificationsViewModel viewModel; + + public static DialogFragment create(@NonNull GroupId groupId) { + DialogFragment fragment = new CustomNotificationsDialogFragment(); + Bundle args = new Bundle(); + + args.putString(ARG_GROUP_ID, groupId.toString()); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (ThemeUtil.isDarkTheme(requireActivity())) { + setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme); + } else { + setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme); + } + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.custom_notifications_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + initializeViewModel(); + initializeViews(view); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (requestCode == RINGTONE_PICKER_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { + Uri uri = data.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + + viewModel.setMessageSound(uri); + } + } + + private void initializeViewModel() { + Bundle arguments = requireArguments(); + GroupId groupId = GroupId.parseOrThrow(arguments.getString(ARG_GROUP_ID, "")); + CustomNotificationsRepository repository = new CustomNotificationsRepository(requireContext(), groupId); + CustomNotificationsViewModel.Factory factory = new CustomNotificationsViewModel.Factory(groupId, repository); + + viewModel = ViewModelProviders.of(this, factory).get(CustomNotificationsViewModel.class); + } + + private void initializeViews(@NonNull View view) { + customNotificationsSwitch = view.findViewById(R.id.custom_notifications_enable_switch); + soundLabel = view.findViewById(R.id.custom_notifications_sound_label); + soundSelector = view.findViewById(R.id.custom_notifications_sound_selection); + vibrateLabel = view.findViewById(R.id.custom_notifications_vibrate_label); + vibrateSwitch = view.findViewById(R.id.custom_notifications_vibrate_switch); + + Toolbar toolbar = view.findViewById(R.id.custom_notifications_toolbar); + + toolbar.setNavigationOnClickListener(v -> requireActivity().finish()); + + CompoundButton.OnCheckedChangeListener onCustomNotificationsSwitchCheckChangedListener = (buttonView, isChecked) -> { + viewModel.setHasCustomNotifications(isChecked); + }; + + viewModel.isInitialLoadComplete().observe(getViewLifecycleOwner(), customNotificationsSwitch::setEnabled); + + viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { + if (customNotificationsSwitch.isChecked() != hasCustomNotifications) { + customNotificationsSwitch.setOnCheckedChangeListener(null); + customNotificationsSwitch.setChecked(hasCustomNotifications); + } + + customNotificationsSwitch.setOnCheckedChangeListener(onCustomNotificationsSwitchCheckChangedListener); + + soundLabel.setEnabled(hasCustomNotifications); + vibrateLabel.setEnabled(hasCustomNotifications); + soundSelector.setVisibility(hasCustomNotifications ? View.VISIBLE : View.GONE); + vibrateSwitch.setVisibility(hasCustomNotifications ? View.VISIBLE : View.GONE); + }); + + CompoundButton.OnCheckedChangeListener onVibrateSwitchCheckChangedListener = (buttonView, isChecked) -> { + viewModel.setMessageVibrate(isChecked ? RecipientDatabase.VibrateState.ENABLED : RecipientDatabase.VibrateState.DISABLED); + }; + + viewModel.getVibrateState().observe(getViewLifecycleOwner(), vibrateState -> { + boolean vibrateEnabled = vibrateState != RecipientDatabase.VibrateState.DISABLED; + + if (vibrateSwitch.isChecked() != vibrateEnabled) { + vibrateSwitch.setOnCheckedChangeListener(null); + vibrateSwitch.setChecked(vibrateEnabled); + } + + vibrateSwitch.setOnCheckedChangeListener(onVibrateSwitchCheckChangedListener); + }); + + viewModel.getNotificationSound().observe(getViewLifecycleOwner(), sound -> { + soundSelector.setText(getRingtoneSummary(requireContext(), sound)); + soundSelector.setTag(sound); + }); + + soundSelector.setOnClickListener(v -> launchSoundSelector(viewModel.getNotificationSound().getValue())); + } + + private @NonNull String getRingtoneSummary(@NonNull Context context, @Nullable Uri ringtone) { + if (ringtone == null) { + return context.getString(R.string.preferences__default); + } else if (ringtone.toString().isEmpty()) { + return context.getString(R.string.preferences__silent); + } else { + Ringtone tone = RingtoneManager.getRingtone(getActivity(), ringtone); + + if (tone != null) { + return tone.getTitle(context); + } + } + + return context.getString(R.string.preferences__default); + } + + private void launchSoundSelector(@Nullable Uri current) { + Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER); + + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_NOTIFICATION); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, Settings.System.DEFAULT_NOTIFICATION_URI); + intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current); + + startActivityForResult(intent, RINGTONE_PICKER_REQUEST_CODE); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsRepository.java new file mode 100644 index 0000000000..0df25b4f4e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsRepository.java @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.groups.ui.notifications; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +class CustomNotificationsRepository { + + private final Context context; + private final GroupId groupId; + + CustomNotificationsRepository(@NonNull Context context, @NonNull GroupId groupId) { + this.context = context; + this.groupId = groupId; + } + + void onLoad(@NonNull Runnable onLoaded) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + + recipientDatabase.setMessageRingtone(recipient.getId(), NotificationChannels.getMessageRingtone(context, recipient)); + recipientDatabase.setMessageVibrate(recipient.getId(), NotificationChannels.getMessageVibrate(context, recipient) ? RecipientDatabase.VibrateState.ENABLED + : RecipientDatabase.VibrateState.DISABLED); + + NotificationChannels.ensureCustomChannelConsistency(context); + + onLoaded.run(); + }); + } + + void setHasCustomNotifications(final boolean hasCustomNotifications) { + SignalExecutors.SERIAL.execute(() -> { + if (hasCustomNotifications) { + createCustomNotificationChannel(); + } else { + deleteCustomNotificationChannel(); + } + }); + } + + void setMessageVibrate(final RecipientDatabase.VibrateState vibrateState) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + + DatabaseFactory.getRecipientDatabase(context).setMessageVibrate(recipient.getId(), vibrateState); + NotificationChannels.updateMessageVibrate(context, recipient, vibrateState); + }); + } + + void setMessageSound(@Nullable Uri sound) { + SignalExecutors.SERIAL.execute(() -> { + Recipient recipient = getRecipient(); + Uri defaultValue = TextSecurePreferences.getNotificationRingtone(context); + Uri newValue; + + if (defaultValue.equals(sound)) newValue = null; + else if (sound == null) newValue = Uri.EMPTY; + else newValue = sound; + + DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(recipient.getId(), newValue); + NotificationChannels.updateMessageRingtone(context, recipient, newValue); + }); + } + + @WorkerThread + private void createCustomNotificationChannel() { + Recipient recipient = getRecipient(); + String channelId = NotificationChannels.createChannelFor(context, recipient); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), channelId); + } + + @WorkerThread + private void deleteCustomNotificationChannel() { + Recipient recipient = getRecipient(); + + DatabaseFactory.getRecipientDatabase(context).setNotificationChannel(recipient.getId(), null); + NotificationChannels.deleteChannelFor(context, recipient); + } + + @WorkerThread + private @NonNull Recipient getRecipient() { + return Recipient.externalGroup(context, groupId).resolve(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsViewModel.java new file mode 100644 index 0000000000..82237b5699 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/notifications/CustomNotificationsViewModel.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.groups.ui.notifications; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.recipients.Recipient; + +public final class CustomNotificationsViewModel extends ViewModel { + + private final LiveGroup liveGroup; + private final LiveData hasCustomNotifications; + private final LiveData isVibrateEnabled; + private final LiveData notificationSound; + private final CustomNotificationsRepository repository; + private final MutableLiveData isInitialLoadComplete = new MutableLiveData<>(); + + private CustomNotificationsViewModel(@NonNull GroupId groupId, @NonNull CustomNotificationsRepository repository) { + this.liveGroup = new LiveGroup(groupId); + this.repository = repository; + this.hasCustomNotifications = Transformations.map(liveGroup.getGroupRecipient(), recipient -> recipient.getNotificationChannel() != null); + this.isVibrateEnabled = Transformations.map(liveGroup.getGroupRecipient(), Recipient::getMessageVibrate); + this.notificationSound = Transformations.map(liveGroup.getGroupRecipient(), Recipient::getMessageRingtone); + + repository.onLoad(() -> isInitialLoadComplete.postValue(true)); + } + + public LiveData isInitialLoadComplete() { + return isInitialLoadComplete; + } + + public LiveData hasCustomNotifications() { + return hasCustomNotifications; + } + + public LiveData getVibrateState() { + return isVibrateEnabled; + } + + public LiveData getNotificationSound() { + return notificationSound; + } + + public void setHasCustomNotifications(boolean hasCustomNotifications) { + repository.setHasCustomNotifications(hasCustomNotifications); + } + + public void setMessageVibrate(@NonNull RecipientDatabase.VibrateState vibrateState) { + repository.setMessageVibrate(vibrateState); + } + + public void setMessageSound(@Nullable Uri sound) { + repository.setMessageSound(sound); + } + + public static final class Factory implements ViewModelProvider.Factory { + + private final GroupId groupId; + private final CustomNotificationsRepository repository; + + public Factory(@NonNull GroupId groupId, @NonNull CustomNotificationsRepository repository) { + this.groupId = groupId; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new CustomNotificationsViewModel(groupId, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 6727516d17..1d4c697d84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -669,6 +669,10 @@ public class Recipient { return System.currentTimeMillis() <= muteUntil; } + public long getMuteUntil() { + return muteUntil; + } + public boolean isBlocked() { return blocked; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 8cf45c8289..3e193f3026 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -42,6 +42,10 @@ public class DateUtils extends android.text.format.DateUtils { return System.currentTimeMillis() - millis <= unit.toMillis(span); } + private static boolean isWithinAbs(final long millis, final long span, final TimeUnit unit) { + return Math.abs(System.currentTimeMillis() - millis) <= unit.toMillis(span); + } + private static boolean isYesterday(final long when) { return DateUtils.isToday(when + TimeUnit.DAYS.toMillis(1)); } @@ -92,6 +96,20 @@ public class DateUtils extends android.text.format.DateUtils { } } + public static String getTimeString(final Context c, final Locale locale, final long timestamp) { + StringBuilder format = new StringBuilder(); + + if (isSameDay(System.currentTimeMillis(), timestamp)) format.append(""); + else if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) format.append("EEE "); + else if (isWithinAbs(timestamp, 364, TimeUnit.DAYS)) format.append("MMM d, "); + else format.append("MMM d, yyyy, "); + + if (DateFormat.is24HourFormat(c)) format.append("HH:mm"); + else format.append("hh:mm a"); + + return getFormattedDateTime(timestamp, format.toString(), locale); + } + public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index 63742de1e9..62d2c20eb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.util.livedata; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; + +import com.annimon.stream.function.Predicate; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.whispersystems.libsignal.util.guava.Function; @@ -14,6 +17,26 @@ public final class LiveDataUtil { private LiveDataUtil() { } + public static @NonNull LiveData filterNotNull(@NonNull LiveData source) { + //noinspection Convert2MethodRef + return filter(source, a -> a != null); + } + + /** + * Filters output of a given live data based off a predicate. + */ + public static @NonNull LiveData filter(@NonNull LiveData source, @NonNull Predicate predicate) { + MediatorLiveData mediator = new MediatorLiveData<>(); + + mediator.addSource(source, newValue -> { + if (predicate.test(newValue)) { + mediator.setValue(newValue); + } + }); + + return mediator; + } + /** * Runs the {@param backgroundFunction} on {@link SignalExecutors#BOUNDED}. *

diff --git a/app/src/main/res/layout/custom_notifications_dialog_fragment.xml b/app/src/main/res/layout/custom_notifications_dialog_fragment.xml new file mode 100644 index 0000000000..56ccf44188 --- /dev/null +++ b/app/src/main/res/layout/custom_notifications_dialog_fragment.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml index 753b5c60a6..f200a0326a 100644 --- a/app/src/main/res/layout/group_manage_fragment.xml +++ b/app/src/main/res/layout/group_manage_fragment.xml @@ -84,6 +84,88 @@ + + + + + + + + + + +