Add undo-ability to call tab deletion.

This commit is contained in:
Alex Hart
2023-03-20 13:16:56 -03:00
committed by Greyson Parrelli
parent 4d735d23b6
commit 1c3636eedd
19 changed files with 262 additions and 67 deletions

View File

@@ -125,7 +125,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
callback.accept(true);
}

View File

@@ -638,6 +638,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private class ListClickListener {
public void onItemClick(ContactSearchKey contact) {
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
SelectedContact selectedContact = contact.requireSelectedContact();
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
@@ -668,7 +669,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
}
@@ -686,7 +687,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
});
} else {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
onContactSelectedListener.onBeforeContactSelected(
isUnknown,
Optional.ofNullable(selectedContact.getRecipientId()),
selectedContact.getNumber(),
allowed -> {
if (allowed) {
markContactSelected(selectedContact);
}
@@ -955,7 +960,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
*/
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);

View File

@@ -136,7 +136,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
callback.accept(true);
}

View File

@@ -102,7 +102,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
boolean smsSupported = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
if (recipientId.isPresent()) {

View File

@@ -97,7 +97,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)

View File

@@ -53,15 +53,24 @@ class CallLogAdapter(
)
}
fun submitCallRows(rows: List<CallLogRow?>, selectionState: CallLogSelectionState) {
submitList(
rows.filterNotNull().map {
fun submitCallRows(
rows: List<CallLogRow?>,
selectionState: CallLogSelectionState,
stagedDeletion: CallLogStagedDeletion?
): Int {
val filteredRows = rows
.filterNotNull()
.filterNot { stagedDeletion?.isStagedForDeletion(it.id) == true }
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
}
}
)
submitList(filteredRows)
return filteredRows.size
}
private class CallModel(
@@ -172,6 +181,7 @@ class CallLogAdapter(
binding.callType.setImageResource(R.drawable.symbol_phone_24)
binding.callType.setOnClickListener { onStartAudioCallClicked(peer) }
}
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener { onStartVideoCallClicked(peer) }

View File

@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
/**
@@ -99,18 +100,19 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
disposables.bindTo(viewLifecycleOwner)
adapter.setPagingController(viewModel.controller)
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) ->
adapter.submitCallRows(data, selected)
val filteredCount = adapter.submitCallRows(data, selected.first, selected.second)
binding.emptyState.visible = filteredCount == 0
}
disposables += Flowables.combineLatest(viewModel.selected, viewModel.totalCount)
disposables += Flowables.combineLatest(viewModel.selectedAndStagedDeletion, viewModel.totalCount)
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (selected, totalCount) ->
if (selected.isNotEmpty(totalCount)) {
callLogActionMode.setCount(selected.count(totalCount))
if (selected.first.isNotEmpty(totalCount)) {
callLogActionMode.setCount(selected.first.count(totalCount))
} else {
callLogActionMode.end()
}
@@ -181,19 +183,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
private fun handleDeleteSelectedRows() {
val count = callLogActionMode.getCount()
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, callLogActionMode.getCount(), callLogActionMode.getCount()))
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
disposables += viewModel.deleteSelection()
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
callLogActionMode.end()
Snackbar.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, it, it),
Snackbar.LENGTH_SHORT
).show()
})
viewModel.stageSelectionDeletion()
callLogActionMode.end()
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, count, count),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
@@ -296,15 +302,18 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
disposables += viewModel.deleteCall(call)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
Snackbar.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, it, it),
Snackbar.LENGTH_SHORT
).show()
})
viewModel.stageCallDeletion(call)
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, 1, 1),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
@@ -356,6 +365,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
private inner class SnackbarDeletionCallback : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
viewModel.commitStagedDeletion()
}
}
interface Callback {
fun onMultiSelectStarted()
fun onMultiSelectFinished()

View File

@@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.calls.log
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -42,16 +42,16 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
fun deleteSelectedCallLogs(
selectedMessageIds: Set<Long>
): Single<Int> {
return Single.fromCallable {
): Completable {
return Completable.fromAction {
SignalDatabase.messages.deleteCallUpdates(selectedMessageIds)
}.observeOn(Schedulers.io())
}
fun deleteAllCallLogsExcept(
selectedMessageIds: Set<Long>
): Single<Int> {
return Single.fromCallable {
): Completable {
return Completable.fromAction {
SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds)
}.observeOn(Schedulers.io())
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.calls.log
import androidx.annotation.MainThread
/**
* Encapsulates a single deletion action
*/
class CallLogStagedDeletion(
private val stateSnapshot: CallLogSelectionState,
private val repository: CallLogRepository
) {
private var isCommitted = false
fun isStagedForDeletion(id: CallLogRow.Id): Boolean {
return stateSnapshot.contains(id)
}
@MainThread
fun cancel() {
isCommitted = true
}
@MainThread
fun commit() {
if (isCommitted) {
return
}
isCommitted = true
val messageIds = stateSnapshot.selected()
.filterIsInstance<CallLogRow.Id.Call>()
.map { it.messageId }
.toSet()
if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(messageIds).subscribe()
} else {
repository.deleteSelectedCallLogs(messageIds).subscribe()
}
}
}

View File

@@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.calls.log
import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.BehaviorProcessor
@@ -31,9 +31,9 @@ class CallLogViewModel(
val controller = ProxyPagingController<CallLogRow.Id>()
val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
val selected: Flowable<CallLogSelectionState> = callLogStore
val selectedAndStagedDeletion: Flowable<Pair<CallLogSelectionState, CallLogStagedDeletion?>> = callLogStore
.stateFlowable
.map { it.selectionState }
.map { it.selectionState to it.stagedDeletion }
val totalCount: Flowable<Int> = Flowable.combineLatest(distinctQueryFilterPairs, data) { a, _ -> a }
.map { (query, filter) -> callLogRepository.getCallsCount(query, filter) }
@@ -73,6 +73,7 @@ class CallLogViewModel(
}
override fun onCleared() {
commitStagedDeletion()
disposables.dispose()
}
@@ -90,8 +91,48 @@ class CallLogViewModel(
}
}
fun deleteCall(call: CallLogRow.Call): Single<Int> {
return callLogRepository.deleteSelectedCallLogs(setOf(call.call.messageId))
@MainThread
fun stageCallDeletion(call: CallLogRow.Call) {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
CallLogSelectionState.empty().toggle(call.id),
callLogRepository
)
)
}
}
@MainThread
fun stageSelectionDeletion() {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.selectionState,
callLogRepository
)
)
}
}
fun commitStagedDeletion() {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = null
)
}
}
fun cancelStagedDeletion() {
callLogStore.state.stagedDeletion?.cancel()
callLogStore.update {
it.copy(
stagedDeletion = null
)
}
}
fun clearSelected() {
@@ -108,23 +149,10 @@ class CallLogViewModel(
callLogStore.update { it.copy(filter = filter) }
}
fun deleteSelection(): Single<Int> {
val stateSnapshot = callLogStore.state
val messageIds: Set<Long> = stateSnapshot.selectionState.selected()
.filterIsInstance<CallLogRow.Id.Call>()
.map { it.messageId }
.toSet()
return if (stateSnapshot.selectionState.isExclusionary()) {
callLogRepository.deleteAllCallLogsExcept(messageIds)
} else {
callLogRepository.deleteSelectedCallLogs(messageIds)
}
}
private data class CallLogState(
val query: String? = null,
val filter: CallLogFilter = CallLogFilter.ALL,
val selectionState: CallLogSelectionState = CallLogSelectionState.empty()
val selectionState: CallLogSelectionState = CallLogSelectionState.empty(),
val stagedDeletion: CallLogStagedDeletion? = null
)
}

View File

@@ -8,11 +8,23 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.app.ActivityCompat
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionActivity
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery.refresh
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import java.io.IOException
import java.util.Optional
import java.util.function.Consumer
class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment.NewCallCallback {
@@ -26,7 +38,60 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
override fun onSelectionChanged() = Unit
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
if (isFromUnknownSearchKey) {
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.")
if (SignalStore.account().isRegistered) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.")
val progress = SimpleProgressDialog.show(this)
SimpleTask.run<Recipient>(lifecycle, {
var resolved = Recipient.external(this, number!!)
if (!resolved.isRegistered || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.")
resolved = try {
refresh(this, resolved, false)
Recipient.resolved(resolved.id)
} catch (e: IOException) {
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.")
return@run null
}
}
resolved
}) { resolved: Recipient? ->
progress.dismiss()
if (resolved != null) {
if (resolved.isRegistered && resolved.hasServiceId()) {
launch(resolved)
} else {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, resolved.getDisplayName(this)))
.setPositiveButton(android.R.string.ok, null)
.show()
}
} else {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
}
callback.accept(true)
}
private fun launch(recipient: Recipient) {
if (recipient.isGroup) {
CommunicationActions.startVideoCall(this, recipient)
} else {
CommunicationActions.startVoiceCall(this, recipient)
}
}
companion object {
private val TAG = Log.tag(NewCallActivity::class.java)
fun createIntent(context: Context): Intent {
return Intent(context, NewCallActivity::class.java)
.putExtra(

View File

@@ -113,7 +113,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
return mode or ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
viewModel.select(recipientId.get())
callback.accept(true)

View File

@@ -79,7 +79,7 @@ public class AddMembersActivity extends PushContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).hasE164()) {
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
callback.accept(false);

View File

@@ -112,7 +112,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.isMulti()) {
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
// if (contactsFragment.hasQueryFilter()) {

View File

@@ -94,7 +94,7 @@ public class CreateGroupActivity extends ContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View File

@@ -71,7 +71,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(recipientId.get()),

View File

@@ -117,7 +117,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
}
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
viewModel.addRecipient(recipientId.get())
searchField.setText("")
callback.accept(true)